diff --git a/agents/librarian/config/base.yaml b/agents/librarian/config/base.yaml index 07a7596..910e1c1 100644 --- a/agents/librarian/config/base.yaml +++ b/agents/librarian/config/base.yaml @@ -44,21 +44,31 @@ persona: | ## 도구 카탈로그 - ### DB 정보 검색 (Doc Store 5 collection, read) + ### DB 정보 검색 (Doc Store, read) + + 도메인 산출물: - `wiki_pages` — 위키 문서 (PRD / ADR / business_rule 등, `page_type` 필드로 분류) - `issues` — 이슈 mirror (Epic / Story / Task 등, `type` 필드로 분류) - - `agent_tasks` — 작업 단위 (CHR 가 영속한 task 메타) - - `agent_sessions` — task 안의 대화 흐름 (contextId 별) - - `agent_items` — 개별 메시지 + + Chat tier (UG↔P/A 영역, #75): + - `sessions` — UG↔P/A 한 대화창 + - `chats` — session 안의 한 발화 (immutable). list_by_session 으로 시간순 조회 + - `assignments` — chat 중 합의된 도메인 work item (P/A 발급) + + A2A tier (에이전트 간, #75): + - `a2a_contexts` — 두 에이전트 사이 대화 namespace (A2A wire `contextId` 와 1:1) + - `a2a_messages` — A2A `Message` (immutable). trivial 또는 Task.history + - `a2a_tasks` — A2A `Task` (stateful work, SUBMITTED → COMPLETED 등) + - `a2a_task_status_updates` — Task state transition 로그 (immutable) + - `a2a_task_artifacts` — Task 산출물 (immutable) ### 외부 리소스 조사 (3 트랙, M6 #62) - context7 — 라이브러리 / 프레임워크 공식 docs - mcp/web-fetch — 사용자 제공 URL 의 페이지 (Playwright 기반) - Claude Web Search — 일반 web 검색 (Claude API native tool) - 자세한 op 인터페이스는 도구 schema (bind_tools) 를 참조하세요. read 13 op - + 조합 쿼리 1 (`chronicler_log_by_context`) = 14 op. write 도구는 노출하지 - 않습니다 (#63 분담 모델 — 각 에이전트가 자기 도메인 데이터 MCP 직접 write). + 자세한 op 인터페이스는 도구 schema (bind_tools) 를 참조하세요. write 도구는 + 노출하지 않습니다 (#63 분담 모델 — 각 에이전트가 자기 도메인 데이터 MCP 직접 write). ## 행동 원칙 @@ -77,12 +87,12 @@ persona: | ## 현재 단계 제약 (M3) - 현재 노출 도구는 Doc Store 5 collection 의 read op + 조합 쿼리 1 개. - Atlas (graph) MCP 연동, 외부 리소스 조사 3 트랙 (context7 / web-fetch / - Claude Web Search) 은 후속 마일스톤에서 추가됩니다. Diff 색인은 본 - 에이전트의 책임이 아니며 (Engineer 자체 색인 — #63 옵션 A 확정), Engineer - 가 자기 변경 diff 를 직접 Atlas / Doc Store MCP 에 write 합니다. 미구현 기능 - 호출이 들어오면 "본 단계에선 미지원" 으로 명시합니다. + 현재 노출 도구는 Doc Store collection (도메인 산출물 + chat tier + a2a tier) + 의 read op. Atlas (graph) MCP 연동, 외부 리소스 조사 3 트랙 (context7 / + web-fetch / Claude Web Search) 은 후속 마일스톤에서 추가됩니다. Diff 색인은 + 본 에이전트의 책임이 아니며 (Engineer 자체 색인 — #63 옵션 A 확정), + Engineer 가 자기 변경 diff 를 직접 Atlas / Doc Store MCP 에 write 합니다. + 미구현 기능 호출이 들어오면 "본 단계에선 미지원" 으로 명시합니다. llm: provider: anthropic @@ -116,9 +126,8 @@ agent_card: - id: librarian.doc_store_query name: Doc Store Query description: | - 자연어 요청을 받아 Doc Store 의 5 collection (wiki_pages / issues / - agent_tasks / agent_sessions / agent_items) 의 read 도구 + 조합 - 쿼리 (chronicler_log_by_context 등) 를 호출하고 결과를 자연어로 - 정리해 응답합니다. write 는 본 에이전트의 책임이 아닙니다 — 각 - 에이전트가 자기 도메인 데이터를 MCP 통해 직접 write (#63 분담 모델). + 자연어 요청을 받아 Doc Store collection (도메인 산출물 + chat tier + + a2a tier) 의 read 도구를 호출하고 결과를 자연어로 정리해 응답합니다. + write 는 본 에이전트의 책임이 아닙니다 — 각 에이전트가 자기 도메인 + 데이터를 MCP 통해 직접 write (#63 분담 모델). tags: [librarian, doc-store, query, read, react] diff --git a/agents/librarian/scripts/verify_sandbox.py b/agents/librarian/scripts/verify_sandbox.py index 0d66972..fb7ce53 100644 --- a/agents/librarian/scripts/verify_sandbox.py +++ b/agents/librarian/scripts/verify_sandbox.py @@ -6,8 +6,8 @@ 검증 시나리오 (read-only — Doc Store 데이터 변경 X): 1. AgentCard 정상 (skill = read 중심 — #64) 2. "wiki_pages.list" 자연어 요청 → wiki_pages_list tool 호출 + 자연어 응답 - 3. "agent_tasks 몇 개" → agent_tasks_list 호출 + count 응답 - 4. chronicler_log_by_context — 조합 쿼리 (#64 신설) + 3. "assignments 몇 개" → assignments_list 호출 + count 응답 (#75 재설계) + 4. a2a_contexts_find_by_context_id — 임의 contextId lookup """ from __future__ import annotations @@ -105,9 +105,9 @@ async def main() -> int: print(f" [{i}] {json.dumps(e, ensure_ascii=False)[:200]}") return 1 - print("\n[3] 'agent_tasks 몇 개?' (count via list)") + print("\n[3] 'assignments 몇 개?' (count via list)") events = await send_streaming_message( - "agent_tasks collection 에 현재 몇 개의 task 가 있는지 list 도구로 확인하고 개수만 알려줘.", + "assignments collection 에 현재 몇 개의 assignment 가 있는지 list 도구로 확인하고 개수만 알려줘.", ) final = _extract_final_text(events) print(f" final text (first 300):\n {final[:300]!r}") @@ -115,9 +115,9 @@ async def main() -> int: print(" !! 자연어 응답 없음") return 1 - print("\n[4] chronicler_log_by_context — 임의 contextId 조회 (조합 쿼리)") + print("\n[4] a2a_contexts_find_by_context_id — 임의 contextId lookup") events = await send_streaming_message( - "context_id 'nonexistent-ctx' 의 chronicler 로그를 chronicler_log_by_context 도구로 조회하고 결과 알려줘. (없으면 없다고)", + "context_id 'nonexistent-ctx' 를 a2a_contexts_find_by_context_id 도구로 lookup 하고 결과 알려줘. (없으면 없다고)", ) final = _extract_final_text(events) print(f" final text (first 300):\n {final[:300]!r}") diff --git a/agents/librarian/src/librarian_agent/tools.py b/agents/librarian/src/librarian_agent/tools.py index d5215e9..d00febd 100644 --- a/agents/librarian/src/librarian_agent/tools.py +++ b/agents/librarian/src/librarian_agent/tools.py @@ -1,21 +1,19 @@ """Librarian 의 LangChain tool 정의. -Doc Store 의 5 collection read 도구 + 조합 쿼리 1 개. LangChain `@tool` 로 -wrapping 해 LLM 의 `bind_tools()` 에 부착 — LLM 이 자연어 → tool 매핑 결정. +Doc Store collection 의 read 도구 + 조합 쿼리. LangChain `@tool` 로 wrapping +해 LLM 의 `bind_tools()` 에 부착 — LLM 이 자연어 → tool 매핑 결정. 도메인 wrapper (`upsert_prd` 등) 박지 않음 (root CLAUDE.md "에이전트 ↔ 외부 도구 운영 원칙"). LLM 이 매 요청 컨텍스트에 맞춰 page_type / type 필드 결정. -분담 모델 (#63 정정 — 2026-05): write 는 각 에이전트가 Doc Store / Atlas -MCP 직접. **Librarian 은 read-only 사서** — 자연어 / 교차 쿼리 매핑. +분담 모델 (#63): write 는 각 에이전트가 Doc Store / Atlas MCP 직접. +**Librarian 은 read-only 사서** — 자연어 / 교차 쿼리 매핑. -M3 노출 op (read 13 + 조합 1 = 14): -- wiki_pages: get / get_by_slug / list -- issues: get / list -- agent_tasks: get / list -- agent_sessions: get / list / list_by_task / find_by_context -- agent_items: list / list_by_session -- 조합: chronicler_log_by_context (session find + items list 결합) +#75 재설계: chat tier (sessions / chats / assignments) + A2A tier +(a2a_contexts / a2a_messages / a2a_tasks / a2a_task_status_updates / +a2a_task_artifacts) + 도메인 산출물 (issues / wiki_pages) 의 read 도구 노출. +조합 쿼리는 PR 2 (Chronicler 재작성 + chat protocol 도입) 후 새 그루핑 단위 +(session, assignment) 기반으로 추가. """ from __future__ import annotations @@ -24,24 +22,19 @@ from uuid import UUID from dev_team_shared.doc_store import ( - AgentItemRead, - AgentSessionRead, - AgentTaskRead, + A2AContextRead, + A2AMessageRead, + A2ATaskArtifactRead, + A2ATaskRead, + A2ATaskStatusUpdateRead, + AssignmentRead, + ChatRead, DocStoreClient, IssueRead, + SessionRead, WikiPageRead, ) from langchain_core.tools import BaseTool, tool -from pydantic import BaseModel, ConfigDict - - -class ChroniclerLog(BaseModel): - """contextId 1 개의 chronicler 로그 = session + items 결합 결과.""" - - model_config = ConfigDict(extra="forbid") - - session: AgentSessionRead | None - items: list[AgentItemRead] def build_tools(client: DocStoreClient) -> list[BaseTool]: @@ -98,111 +91,171 @@ async def issues_list( ) # ────────────────────────────────────────────────────────────────── - # agent_tasks (read) + # assignments (도메인 work item) — read # ────────────────────────────────────────────────────────────────── @tool - async def agent_tasks_get(id: str) -> AgentTaskRead | None: - """Get agent_task by id (UUID). 미존재 시 null.""" - return await client.agent_task_get(UUID(id)) + async def assignments_get(id: str) -> AssignmentRead | None: + """Get assignment by id (UUID). 미존재 시 null.""" + return await client.assignment_get(UUID(id)) @tool - async def agent_tasks_list( + async def assignments_list( where: dict[str, Any] | None = None, limit: int = 100, offset: int = 0, order_by: str = "created_at DESC", - ) -> list[AgentTaskRead]: - """List agent tasks. Chronicler 가 영속한 작업 단위 메타.""" - return await client.agent_task_list( + ) -> list[AssignmentRead]: + """List assignments. where 예: {"status": "open"} or {"owner_agent": "primary"}.""" + return await client.assignment_list( + where=where, limit=limit, offset=offset, order_by=order_by, + ) + + @tool + async def assignments_list_by_session(root_session_id: str) -> list[AssignmentRead]: + """List assignments derived from a chat session (UUID).""" + return await client.assignment_list_by_session(UUID(root_session_id)) + + # ────────────────────────────────────────────────────────────────── + # sessions (chat tier) — read + # ────────────────────────────────────────────────────────────────── + + @tool + async def sessions_get(id: str) -> SessionRead | None: + """Get chat session by id (UUID). 미존재 시 null.""" + return await client.session_get(UUID(id)) + + @tool + async def sessions_list( + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "started_at DESC", + ) -> list[SessionRead]: + """List chat sessions (UG↔P/A 대화창).""" + return await client.session_list( where=where, limit=limit, offset=offset, order_by=order_by, ) # ────────────────────────────────────────────────────────────────── - # agent_sessions (read) + # chats (chat tier) — read + # ────────────────────────────────────────────────────────────────── + + @tool + async def chats_list_by_session(session_id: str) -> list[ChatRead]: + """List chats (메시지) within a session (UUID), ordered by created_at.""" + return await client.chat_list_by_session(UUID(session_id)) + + # ────────────────────────────────────────────────────────────────── + # a2a_contexts (A2A tier) — read # ────────────────────────────────────────────────────────────────── @tool - async def agent_sessions_get(id: str) -> AgentSessionRead | None: - """Get agent_session by id (UUID). 미존재 시 null.""" - return await client.agent_session_get(UUID(id)) + async def a2a_contexts_get(id: str) -> A2AContextRead | None: + """Get a2a_context by id (UUID). 미존재 시 null.""" + return await client.a2a_context_get(UUID(id)) @tool - async def agent_sessions_list( + async def a2a_contexts_list( where: dict[str, Any] | None = None, limit: int = 100, offset: int = 0, order_by: str = "started_at DESC", - ) -> list[AgentSessionRead]: - """List agent sessions (대화 흐름).""" - return await client.agent_session_list( + ) -> list[A2AContextRead]: + """List a2a_contexts. where 예: {"trace_id": "..."}.""" + return await client.a2a_context_list( where=where, limit=limit, offset=offset, order_by=order_by, ) @tool - async def agent_sessions_list_by_task(task_id: str) -> list[AgentSessionRead]: - """List sessions for a task (UUID).""" - return await client.agent_session_list_by_task(UUID(task_id)) + async def a2a_contexts_find_by_context_id(context_id: str) -> A2AContextRead | None: + """A2A wire contextId 로 가장 최근 1건 lookup. 미존재 시 null.""" + return await client.a2a_context_find_by_context_id(context_id) + + # ────────────────────────────────────────────────────────────────── + # a2a_messages (A2A tier) — read + # ────────────────────────────────────────────────────────────────── + + @tool + async def a2a_messages_list_by_context(a2a_context_id: str) -> list[A2AMessageRead]: + """List messages within an a2a_context (UUID).""" + return await client.a2a_message_list_by_context(UUID(a2a_context_id)) @tool - async def agent_sessions_find_by_context(context_id: str) -> AgentSessionRead | None: - """contextId 로 session 1개 lookup. 미존재 시 null. chronicler 로그 entry.""" - return await client.agent_session_find_by_context(context_id) + async def a2a_messages_list_by_task(a2a_task_id: str) -> list[A2AMessageRead]: + """List Task.history messages of an a2a_task (UUID).""" + return await client.a2a_message_list_by_task(UUID(a2a_task_id)) # ────────────────────────────────────────────────────────────────── - # agent_items (read) + # a2a_tasks (A2A tier) — read # ────────────────────────────────────────────────────────────────── @tool - async def agent_items_list( + async def a2a_tasks_get(id: str) -> A2ATaskRead | None: + """Get a2a_task by id (UUID). 미존재 시 null.""" + return await client.a2a_task_get(UUID(id)) + + @tool + async def a2a_tasks_list( where: dict[str, Any] | None = None, limit: int = 100, offset: int = 0, - order_by: str = "created_at ASC", - ) -> list[AgentItemRead]: - """List agent items (메시지).""" - return await client.agent_item_list( + order_by: str = "submitted_at DESC", + ) -> list[A2ATaskRead]: + """List a2a_tasks. where 예: {"state": "WORKING"}.""" + return await client.a2a_task_list( where=where, limit=limit, offset=offset, order_by=order_by, ) @tool - async def agent_items_list_by_session(session_id: str) -> list[AgentItemRead]: - """List items for a session (UUID). chronicler 로그 본문.""" - return await client.agent_item_list_by_session(UUID(session_id)) + async def a2a_tasks_find_by_task_id(task_id: str) -> A2ATaskRead | None: + """A2A wire taskId 로 가장 최근 1건 lookup. 미존재 시 null.""" + return await client.a2a_task_find_by_task_id(task_id) # ────────────────────────────────────────────────────────────────── - # 조합 쿼리 (composite read) + # a2a_task_status_updates / a2a_task_artifacts (A2A tier) — read # ────────────────────────────────────────────────────────────────── @tool - async def chronicler_log_by_context(context_id: str) -> ChroniclerLog: - """contextId 의 chronicler 로그 (session + items) 한 번에 조회. + async def a2a_task_status_updates_list_by_task( + a2a_task_id: str, + ) -> list[A2ATaskStatusUpdateRead]: + """List state transitions of an a2a_task (UUID), ordered by transitioned_at.""" + return await client.a2a_task_status_update_list_by_task(UUID(a2a_task_id)) - `agent_sessions_find_by_context` + `agent_items_list_by_session` 두 - 호출을 합친 조합 쿼리. session 미존재 시 items 는 빈 리스트. - """ - session = await client.agent_session_find_by_context(context_id) - if session is None: - return ChroniclerLog(session=None, items=[]) - items = await client.agent_item_list_by_session(session.id) - return ChroniclerLog(session=session, items=items) + @tool + async def a2a_task_artifacts_list_by_task( + a2a_task_id: str, + ) -> list[A2ATaskArtifactRead]: + """List artifacts of an a2a_task (UUID), ordered by created_at.""" + return await client.a2a_task_artifact_list_by_task(UUID(a2a_task_id)) return [ + # 도메인 산출물 wiki_pages_get, wiki_pages_get_by_slug, wiki_pages_list, issues_get, issues_list, - agent_tasks_get, - agent_tasks_list, - agent_sessions_get, - agent_sessions_list, - agent_sessions_list_by_task, - agent_sessions_find_by_context, - agent_items_list, - agent_items_list_by_session, - chronicler_log_by_context, + # chat tier + assignments_get, + assignments_list, + assignments_list_by_session, + sessions_get, + sessions_list, + chats_list_by_session, + # a2a tier + a2a_contexts_get, + a2a_contexts_list, + a2a_contexts_find_by_context_id, + a2a_messages_list_by_context, + a2a_messages_list_by_task, + a2a_tasks_get, + a2a_tasks_list, + a2a_tasks_find_by_task_id, + a2a_task_status_updates_list_by_task, + a2a_task_artifacts_list_by_task, ] -__all__ = ["build_tools", "ChroniclerLog"] +__all__ = ["build_tools"] diff --git a/agents/librarian/tests/test_tools.py b/agents/librarian/tests/test_tools.py index 38940a7..6d67608 100644 --- a/agents/librarian/tests/test_tools.py +++ b/agents/librarian/tests/test_tools.py @@ -1,6 +1,9 @@ """build_tools 단위 테스트 — DocStoreClient mock 으로 tool ↔ client 메서드 매핑 검증. -분담 모델 정정 (#63 / #64) 후: write 도구 미노출. read 13 op + 조합 1 = 14 op. +#75 재설계 후: chat tier (sessions / chats / assignments) + A2A tier +(a2a_contexts / a2a_messages / a2a_tasks / a2a_task_status_updates / +a2a_task_artifacts) + 도메인 산출물 (issues / wiki_pages) 의 read 도구. +write 도구는 미노출 (#63 분담 모델). """ from __future__ import annotations @@ -10,12 +13,11 @@ from uuid import UUID import pytest - from dev_team_shared.doc_store import ( - AgentItemRead, - AgentSessionRead, - AgentTaskRead, + A2AContextRead, + AssignmentRead, IssueRead, + SessionRead, WikiPageRead, ) @@ -29,46 +31,64 @@ def _now() -> datetime: @pytest.fixture def mock_client(): """DocStoreClient 의 모든 메서드를 AsyncMock 으로 stub.""" - client = AsyncMock() - return client + return AsyncMock() def test_build_tools_returns_expected_tool_names(mock_client) -> None: tools = build_tools(mock_client) names = sorted(t.name for t in tools) assert names == sorted([ + # 도메인 산출물 "wiki_pages_get", "wiki_pages_get_by_slug", "wiki_pages_list", "issues_get", "issues_list", - "agent_tasks_get", - "agent_tasks_list", - "agent_sessions_get", - "agent_sessions_list", - "agent_sessions_list_by_task", - "agent_sessions_find_by_context", - "agent_items_list", - "agent_items_list_by_session", - "chronicler_log_by_context", + # chat tier + "assignments_get", + "assignments_list", + "assignments_list_by_session", + "sessions_get", + "sessions_list", + "chats_list_by_session", + # a2a tier + "a2a_contexts_get", + "a2a_contexts_list", + "a2a_contexts_find_by_context_id", + "a2a_messages_list_by_context", + "a2a_messages_list_by_task", + "a2a_tasks_get", + "a2a_tasks_list", + "a2a_tasks_find_by_task_id", + "a2a_task_status_updates_list_by_task", + "a2a_task_artifacts_list_by_task", ]) def test_build_tools_excludes_write_ops(mock_client) -> None: - """write 도구는 #64 시점에 제거. read 사서 정체성 일관.""" + """write 도구는 미노출 — read 사서 정체성 (#63).""" tools = build_tools(mock_client) names = {t.name for t in tools} forbidden = { "wiki_pages_create", "wiki_pages_update", "issues_create", "issues_update", + "assignments_create", "assignments_update", + "sessions_create", "sessions_update", + "a2a_contexts_create", "a2a_messages_create", + "a2a_tasks_create", "a2a_tasks_update", } assert names.isdisjoint(forbidden) +# ────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────── + + def _wiki_page_read(slug: str = "prd-x") -> WikiPageRead: return WikiPageRead( id=UUID("00000000-0000-0000-0000-000000000001"), - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), + assignment_id=UUID("00000000-0000-0000-0000-000000000099"), page_type="prd", slug=slug, title="PRD X", @@ -90,7 +110,7 @@ def _wiki_page_read(slug: str = "prd-x") -> WikiPageRead: def _issue_read() -> IssueRead: return IssueRead( id=UUID("00000000-0000-0000-0000-000000000002"), - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), + assignment_id=UUID("00000000-0000-0000-0000-000000000099"), type="epic", title="Epic 1", body_md="...", @@ -106,35 +126,54 @@ def _issue_read() -> IssueRead: ) -def _agent_session_read(sid: str = "00000000-0000-0000-0000-000000000003") -> AgentSessionRead: - return AgentSessionRead( - id=UUID(sid), - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), - initiator="primary", - counterpart="user-gateway", - context_id="ctx-1", - trace_id=None, - topic=None, +def _session_read() -> SessionRead: + return SessionRead( + id=UUID("00000000-0000-0000-0000-000000000003"), + agent_endpoint="primary", + initiator="user", + counterpart="primary", metadata={}, started_at=_now(), ended_at=None, ) -def _agent_item_read(iid: str = "00000000-0000-0000-0000-000000000010") -> AgentItemRead: - return AgentItemRead( - id=UUID(iid), - agent_session_id=UUID("00000000-0000-0000-0000-000000000003"), - prev_item_id=None, - role="user", - sender="primary", - content={"text": "hello"}, - message_id=None, +def _assignment_read() -> AssignmentRead: + return AssignmentRead( + id=UUID("00000000-0000-0000-0000-000000000099"), + title="결제 모듈 추가", + description=None, + status="open", + owner_agent="primary", + root_session_id=UUID("00000000-0000-0000-0000-000000000003"), + issue_refs=[], metadata={}, created_at=_now(), + updated_at=_now(), + ) + + +def _a2a_context_read() -> A2AContextRead: + return A2AContextRead( + id=UUID("00000000-0000-0000-0000-000000000004"), + context_id="ctx-1", + initiator_agent="primary", + counterpart_agent="engineer", + parent_session_id=None, + parent_assignment_id=None, + trace_id=None, + topic=None, + metadata={}, + started_at=_now(), + ended_at=None, ) +# ────────────────────────────────────────────────────────────────── +# 도메인 산출물 — forwarding +# ────────────────────────────────────────────────────────────────── + + @pytest.mark.asyncio async def test_wiki_pages_get_forwards_uuid(mock_client) -> None: tools = {t.name: t for t in build_tools(mock_client)} @@ -163,74 +202,73 @@ async def test_issues_list_forwards_args(mock_client) -> None: ) +# ────────────────────────────────────────────────────────────────── +# Chat tier — forwarding +# ────────────────────────────────────────────────────────────────── + + @pytest.mark.asyncio -async def test_agent_sessions_find_by_context_forwards(mock_client) -> None: +async def test_sessions_get_forwards_uuid(mock_client) -> None: tools = {t.name: t for t in build_tools(mock_client)} - mock_client.agent_session_find_by_context = AsyncMock(return_value=None) + expected = _session_read() + mock_client.session_get = AsyncMock(return_value=expected) - result = await tools["agent_sessions_find_by_context"].ainvoke({"context_id": "ctx-1"}) - assert result is None - mock_client.agent_session_find_by_context.assert_awaited_once_with("ctx-1") + sid = "00000000-0000-0000-0000-000000000003" + result = await tools["sessions_get"].ainvoke({"id": sid}) + assert result == expected + mock_client.session_get.assert_awaited_once_with(UUID(sid)) @pytest.mark.asyncio -async def test_agent_items_list_by_session_converts_uuid(mock_client) -> None: +async def test_assignments_list_by_session_forwards(mock_client) -> None: tools = {t.name: t for t in build_tools(mock_client)} - mock_client.agent_item_list_by_session = AsyncMock(return_value=[]) - - sid_str = "00000000-0000-0000-0000-000000000003" - await tools["agent_items_list_by_session"].ainvoke({"session_id": sid_str}) - mock_client.agent_item_list_by_session.assert_awaited_once_with(UUID(sid_str)) + mock_client.assignment_list_by_session = AsyncMock( + return_value=[_assignment_read()], + ) + sid = "00000000-0000-0000-0000-000000000003" + await tools["assignments_list_by_session"].ainvoke({"root_session_id": sid}) + mock_client.assignment_list_by_session.assert_awaited_once_with(UUID(sid)) @pytest.mark.asyncio -async def test_wiki_pages_list_forwards_args(mock_client) -> None: +async def test_chats_list_by_session_forwards(mock_client) -> None: tools = {t.name: t for t in build_tools(mock_client)} - mock_client.wiki_page_list = AsyncMock(return_value=[]) - - await tools["wiki_pages_list"].ainvoke({ - "where": {"page_type": "prd"}, - "limit": 50, - "offset": 0, - "order_by": "created_at DESC", - }) - mock_client.wiki_page_list.assert_awaited_once_with( - where={"page_type": "prd"}, limit=50, offset=0, order_by="created_at DESC", - ) + mock_client.chat_list_by_session = AsyncMock(return_value=[]) + sid = "00000000-0000-0000-0000-000000000003" + await tools["chats_list_by_session"].ainvoke({"session_id": sid}) + mock_client.chat_list_by_session.assert_awaited_once_with(UUID(sid)) # ────────────────────────────────────────────────────────────────── -# chronicler_log_by_context (composite read — #64) +# A2A tier — forwarding # ────────────────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_chronicler_log_by_context_combines_session_and_items(mock_client) -> None: - """session 존재 → find_by_context + list_by_session 두 번 호출 후 결합.""" +async def test_a2a_contexts_find_by_context_id_forwards(mock_client) -> None: tools = {t.name: t for t in build_tools(mock_client)} - session = _agent_session_read() - items = [_agent_item_read()] - mock_client.agent_session_find_by_context = AsyncMock(return_value=session) - mock_client.agent_item_list_by_session = AsyncMock(return_value=items) + expected = _a2a_context_read() + mock_client.a2a_context_find_by_context_id = AsyncMock(return_value=expected) - result = await tools["chronicler_log_by_context"].ainvoke({"context_id": "ctx-1"}) - - assert result.session == session - assert result.items == items - mock_client.agent_session_find_by_context.assert_awaited_once_with("ctx-1") - mock_client.agent_item_list_by_session.assert_awaited_once_with(session.id) + result = await tools["a2a_contexts_find_by_context_id"].ainvoke( + {"context_id": "ctx-1"}, + ) + assert result == expected + mock_client.a2a_context_find_by_context_id.assert_awaited_once_with("ctx-1") @pytest.mark.asyncio -async def test_chronicler_log_by_context_returns_empty_when_session_missing(mock_client) -> None: - """session 미존재 → list_by_session 호출 X. items 빈 리스트.""" +async def test_a2a_messages_list_by_task_forwards(mock_client) -> None: tools = {t.name: t for t in build_tools(mock_client)} - mock_client.agent_session_find_by_context = AsyncMock(return_value=None) - mock_client.agent_item_list_by_session = AsyncMock(return_value=[]) + mock_client.a2a_message_list_by_task = AsyncMock(return_value=[]) + tid = "00000000-0000-0000-0000-000000000005" + await tools["a2a_messages_list_by_task"].ainvoke({"a2a_task_id": tid}) + mock_client.a2a_message_list_by_task.assert_awaited_once_with(UUID(tid)) - result = await tools["chronicler_log_by_context"].ainvoke({"context_id": "ctx-missing"}) - assert result.session is None - assert result.items == [] - mock_client.agent_session_find_by_context.assert_awaited_once_with("ctx-missing") - mock_client.agent_item_list_by_session.assert_not_awaited() +@pytest.mark.asyncio +async def test_a2a_tasks_find_by_task_id_forwards(mock_client) -> None: + tools = {t.name: t for t in build_tools(mock_client)} + mock_client.a2a_task_find_by_task_id = AsyncMock(return_value=None) + await tools["a2a_tasks_find_by_task_id"].ainvoke({"task_id": "task-xyz"}) + mock_client.a2a_task_find_by_task_id.assert_awaited_once_with("task-xyz") diff --git a/agents/primary/src/primary_agent/tools/doc_store.py b/agents/primary/src/primary_agent/tools/doc_store.py index e86f543..6a475e3 100644 --- a/agents/primary/src/primary_agent/tools/doc_store.py +++ b/agents/primary/src/primary_agent/tools/doc_store.py @@ -1,7 +1,10 @@ """Doc Store MCP 채널 LangChain tools. -Primary 자기 도메인 (PRD / Epic / Story / Wiki) 의 직접 write / read. -Chronicler 가 영속한 agent_tasks 메타도 read 노출 (자기 도메인 task 추적용). +Primary 자기 도메인 (PRD / Epic / Story / Wiki + Assignment) 의 직접 write / +read. #75 재설계로 agent_tasks 가 assignments 로 재정의됨. + +Assignment 의 명시 발급 / 관리는 chat protocol 도입 (PR 4) 시점에 추가될 +예정. PR 1 단계에선 Doc Store 9 op (wiki_pages 5 + issues 4) 만 노출. """ from __future__ import annotations @@ -10,7 +13,6 @@ from uuid import UUID from dev_team_shared.doc_store import ( - AgentTaskRead, DocStoreClient, IssueCreate, IssueRead, @@ -23,7 +25,7 @@ def build_doc_store_tools(client: DocStoreClient) -> list[BaseTool]: - """Doc Store 채널의 11 op LangChain tool 묶음.""" + """Doc Store 채널의 LangChain tool 묶음.""" @tool async def wiki_pages_create(doc: WikiPageCreate) -> WikiPageRead: @@ -84,23 +86,6 @@ async def issues_list( where=where, limit=limit, offset=offset, order_by=order_by, ) - @tool - async def agent_tasks_get(id: str) -> AgentTaskRead | None: - """Doc Store 의 agent_task 조회 (UUID). Chronicler 가 영속한 작업 메타. 미존재 시 null.""" - return await client.agent_task_get(UUID(id)) - - @tool - async def agent_tasks_list( - where: dict[str, Any] | None = None, - limit: int = 100, - offset: int = 0, - order_by: str = "created_at DESC", - ) -> list[AgentTaskRead]: - """Doc Store 의 agent_tasks 리스트.""" - return await client.agent_task_list( - where=where, limit=limit, offset=offset, order_by=order_by, - ) - return [ wiki_pages_create, wiki_pages_update, @@ -111,8 +96,6 @@ async def agent_tasks_list( issues_update, issues_get, issues_list, - agent_tasks_get, - agent_tasks_list, ] diff --git a/agents/primary/tests/test_tools.py b/agents/primary/tests/test_tools.py index 39c1134..c962638 100644 --- a/agents/primary/tests/test_tools.py +++ b/agents/primary/tests/test_tools.py @@ -25,7 +25,7 @@ def _now() -> datetime: def _wiki_page_read(slug: str = "prd-x") -> DocWikiPageRead: return DocWikiPageRead( id=UUID("00000000-0000-0000-0000-000000000001"), - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), + assignment_id=UUID("00000000-0000-0000-0000-000000000099"), page_type="prd", slug=slug, title="PRD X", @@ -47,7 +47,7 @@ def _wiki_page_read(slug: str = "prd-x") -> DocWikiPageRead: def _issue_read() -> DocIssueRead: return DocIssueRead( id=UUID("00000000-0000-0000-0000-000000000002"), - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), + assignment_id=UUID("00000000-0000-0000-0000-000000000099"), type="epic", title="Epic 1", body_md="...", @@ -104,7 +104,7 @@ def test_only_doc_store_when_others_off(doc_store) -> None: doc_store=doc_store, issue_tracker=None, wiki=None, librarian=None, ) names = {t.name for t in tools} - # Doc Store 채널 11 op + # Doc Store 채널 9 op (#75 재설계 후 — agent_tasks_* 제거) assert "wiki_pages_create" in names assert "wiki_pages_update" in names assert "wiki_pages_get" in names @@ -114,8 +114,6 @@ def test_only_doc_store_when_others_off(doc_store) -> None: assert "issues_update" in names assert "issues_get" in names assert "issues_list" in names - assert "agent_tasks_get" in names - assert "agent_tasks_list" in names # 다른 채널 미노출 assert not any(n.startswith("external_") for n in names) assert "librarian_query" not in names @@ -127,7 +125,7 @@ def test_all_channels_on(doc_store, issue_tracker, wiki, librarian) -> None: ) names = {t.name for t in tools} # Doc Store - assert {"wiki_pages_create", "issues_create", "agent_tasks_list"} <= names + assert {"wiki_pages_create", "issues_create", "wiki_pages_list"} <= names # IssueTracker (외부) assert { "external_issue_create", "external_issue_update", "external_issue_list", @@ -157,7 +155,7 @@ async def test_wiki_pages_create_forwards(doc_store) -> None: doc_store.wiki_page_create = AsyncMock(return_value=expected) doc = DocWikiPageCreate( - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), + assignment_id=UUID("00000000-0000-0000-0000-000000000099"), page_type="prd", slug="prd-x", title="PRD X", @@ -178,7 +176,7 @@ async def test_issues_create_forwards(doc_store) -> None: doc_store.issue_create = AsyncMock(return_value=expected) doc = DocIssueCreate( - agent_task_id=UUID("00000000-0000-0000-0000-000000000099"), + assignment_id=UUID("00000000-0000-0000-0000-000000000099"), type="epic", title="Epic 1", body_md="...", diff --git a/chronicler/src/chronicler/processors/item_append.py b/chronicler/src/chronicler/processors/item_append.py index 77a007f..5865801 100644 --- a/chronicler/src/chronicler/processors/item_append.py +++ b/chronicler/src/chronicler/processors/item_append.py @@ -1,81 +1,33 @@ -"""ItemAppendProcessor.""" +"""ItemAppendProcessor — #75 재설계 중 stub. + +PR 2 에서 새 schema (chat / a2a layer 별) 기반으로 재작성 예정. +""" from __future__ import annotations import logging from typing import ClassVar -from dev_team_shared.doc_store import ( - AgentItemCreate, - DocStoreClient, -) -from dev_team_shared.event_bus.events import A2AEvent, ItemAppendEvent, SessionStartEvent +from dev_team_shared.doc_store import DocStoreClient +from dev_team_shared.event_bus.events import A2AEvent, ItemAppendEvent from chronicler.processors.base import EventProcessor -from chronicler.processors.session_start import SessionStartProcessor logger = logging.getLogger(__name__) class ItemAppendProcessor(EventProcessor): - """메시지 1건 — message_id 기반 dedup. session 누락 시 자동 생성 (synthesize).""" + """#75 PR 2 재작성 대기. 현재는 no-op.""" event_type: ClassVar[type[A2AEvent]] = ItemAppendEvent - async def process(self, event: A2AEvent, db: DocStoreClient) -> None: + async def process(self, event: A2AEvent, db: DocStoreClient) -> None: # noqa: ARG002 assert isinstance(event, ItemAppendEvent) - - session = await db.agent_session_find_by_context(event.context_id) - if session is None: - # session.start 누락 — 비정상이지만 데이터 보존 위해 synthesize - logger.warning( - "item.append no session for context_id=%s — synthesizing session.start", - event.context_id, - ) - await SessionStartProcessor().process( - SessionStartEvent( - event_id=event.event_id + ".synth", - context_id=event.context_id, - trace_id=event.trace_id, - initiator=event.initiator, - counterpart=event.counterpart, - agent_task_id=event.agent_task_id, - ), - db, - ) - session = await db.agent_session_find_by_context(event.context_id) - - if session is None: - logger.error( - "item.append synthesized session.start still failed context_id=%s", - event.context_id, - ) - return - - # message_id 기반 중복 검사 (best effort idempotency) - if event.message_id: - existing = await db.agent_item_list( - where={ - "agent_session_id": str(session.id), - "message_id": event.message_id, - }, - limit=1, - ) - if existing: - logger.debug( - "item.append skip — duplicate message_id=%s", event.message_id, - ) - return - - await db.agent_item_create(AgentItemCreate( - agent_session_id=session.id, - prev_item_id=event.prev_item_id, - role=event.role, - sender=event.sender, - content=event.content, - message_id=event.message_id, - metadata=event.metadata, - )) + logger.warning( + "item.append received but processor is stubbed (#75 PR 2 will rewrite) " + "context_id=%s role=%s sender=%s", + event.context_id, event.role, event.sender, + ) __all__ = ["ItemAppendProcessor"] diff --git a/chronicler/src/chronicler/processors/session_end.py b/chronicler/src/chronicler/processors/session_end.py index 30463b5..ef25fb0 100644 --- a/chronicler/src/chronicler/processors/session_end.py +++ b/chronicler/src/chronicler/processors/session_end.py @@ -1,14 +1,14 @@ -"""SessionEndProcessor.""" +"""SessionEndProcessor — #75 재설계 중 stub. + +PR 2 에서 새 schema (chat / a2a layer 별) 기반으로 재작성 예정. +""" from __future__ import annotations import logging from typing import ClassVar -from dev_team_shared.doc_store import ( - AgentSessionUpdate, - DocStoreClient, -) +from dev_team_shared.doc_store import DocStoreClient from dev_team_shared.event_bus.events import A2AEvent, SessionEndEvent from chronicler.processors.base import EventProcessor @@ -17,31 +17,16 @@ class SessionEndProcessor(EventProcessor): - """세션 종료 — ended_at + reason / duration 메타 갱신.""" + """#75 PR 2 재작성 대기. 현재는 no-op.""" event_type: ClassVar[type[A2AEvent]] = SessionEndEvent - async def process(self, event: A2AEvent, db: DocStoreClient) -> None: + async def process(self, event: A2AEvent, db: DocStoreClient) -> None: # noqa: ARG002 assert isinstance(event, SessionEndEvent) - session = await db.agent_session_find_by_context(event.context_id) - if session is None: - logger.warning( - "session.end no session for context_id=%s", event.context_id, - ) - return - - meta = dict(session.metadata) - meta.setdefault("end_reason", event.reason) - if event.duration_ms is not None: - meta.setdefault("duration_ms", event.duration_ms) - meta.update(event.metadata) - - await db.agent_session_update( - session.id, - AgentSessionUpdate( - ended_at=event.timestamp, - metadata=meta, - ), + logger.warning( + "session.end received but processor is stubbed (#75 PR 2 will rewrite) " + "context_id=%s reason=%s duration_ms=%s", + event.context_id, event.reason, event.duration_ms, ) diff --git a/chronicler/src/chronicler/processors/session_start.py b/chronicler/src/chronicler/processors/session_start.py index 35b063f..6fc05b9 100644 --- a/chronicler/src/chronicler/processors/session_start.py +++ b/chronicler/src/chronicler/processors/session_start.py @@ -1,15 +1,17 @@ -"""SessionStartProcessor.""" +"""SessionStartProcessor — #75 재설계 중 stub. + +PR 1 (#75) 의 cut-over 로 기존 agent_tasks / agent_sessions / agent_items 가 +삭제되어 본 processor 의 적재 로직이 동작 못 함. PR 2 에서 새 schema (chat +tier: sessions / chats / assignments + a2a tier) 기반으로 재작성 예정 — 그 +사이엔 no-op (이벤트 받으면 warn-log 만). +""" from __future__ import annotations import logging from typing import ClassVar -from dev_team_shared.doc_store import ( - AgentSessionCreate, - AgentTaskCreate, - DocStoreClient, -) +from dev_team_shared.doc_store import DocStoreClient from dev_team_shared.event_bus.events import A2AEvent, SessionStartEvent from chronicler.processors.base import EventProcessor @@ -18,47 +20,17 @@ class SessionStartProcessor(EventProcessor): - """세션 시작 — find_by_context 로 idempotent. agent_task_id 미지정 시 fallback task 생성.""" + """#75 PR 2 재작성 대기. 현재는 no-op.""" event_type: ClassVar[type[A2AEvent]] = SessionStartEvent - async def process(self, event: A2AEvent, db: DocStoreClient) -> None: + async def process(self, event: A2AEvent, db: DocStoreClient) -> None: # noqa: ARG002 assert isinstance(event, SessionStartEvent) - - # 1) 기존 session 있으면 skip (idempotent) - existing = await db.agent_session_find_by_context(event.context_id) - if existing is not None: - logger.debug( - "session.start skip — existing session for context_id=%s", - event.context_id, - ) - return - - # 2) agent_task_id 없으면 임시 task 생성 (#34 fallback) - agent_task_id = event.agent_task_id - if agent_task_id is None: - ts = event.timestamp.isoformat(timespec="seconds") - task = await db.agent_task_create(AgentTaskCreate( - title=f"{event.initiator} ↔ {event.counterpart} @ {ts}", - owner_agent=event.counterpart, - metadata={"created_by": "chronicler-fallback"}, - )) - agent_task_id = task.id - logger.info( - "session.start fallback task created task_id=%s context_id=%s", - agent_task_id, event.context_id, - ) - - # 3) session 생성 - await db.agent_session_create(AgentSessionCreate( - agent_task_id=agent_task_id, - initiator=event.initiator, - counterpart=event.counterpart, - context_id=event.context_id, - trace_id=event.trace_id, - topic=event.topic, - metadata=event.metadata, - )) + logger.warning( + "session.start received but processor is stubbed (#75 PR 2 will rewrite) " + "context_id=%s initiator=%s counterpart=%s", + event.context_id, event.initiator, event.counterpart, + ) __all__ = ["SessionStartProcessor"] diff --git a/chronicler/tests/test_handler.py b/chronicler/tests/test_handler.py index 46c80df..bb42a3e 100644 --- a/chronicler/tests/test_handler.py +++ b/chronicler/tests/test_handler.py @@ -1,66 +1,29 @@ """EventHandler + Processors 단위 테스트. -DocStoreClient 를 mock 으로 주입 — wire-level (도구명 / dict / JSON parse) 은 -client 안에 격리되어 본 테스트는 typed 메서드만 검증. +#75 PR 1 cut-over 후: 모든 processor 가 stub (no-op) 상태. 본 테스트는 dispatch +구조만 검증 (stub 의 실제 처리 로직은 PR 2 에서 재작성). """ from __future__ import annotations -import uuid -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest - -from chronicler.handler import EventHandler -from chronicler.processors import ALL_PROCESSORS, EventProcessor -from chronicler.processors.session_start import SessionStartProcessor -from dev_team_shared.doc_store import ( - AgentSessionRead, - AgentTaskRead, -) from dev_team_shared.event_bus.events import ( ItemAppendEvent, SessionEndEvent, SessionStartEvent, ) +from chronicler.handler import EventHandler +from chronicler.processors import ALL_PROCESSORS, EventProcessor +from chronicler.processors.session_start import SessionStartProcessor + def _make_handler(db_mock: MagicMock) -> EventHandler: return EventHandler(ALL_PROCESSORS, db_mock) -def _fake_task() -> AgentTaskRead: - now = datetime.now(tz=timezone.utc) - return AgentTaskRead( - id=uuid.uuid4(), - title="t", - description=None, - status="open", - owner_agent="primary", - issue_refs=[], - metadata={}, - created_at=now, - updated_at=now, - ) - - -def _fake_session(*, context_id: str = "ctx-1") -> AgentSessionRead: - now = datetime.now(tz=timezone.utc) - return AgentSessionRead( - id=uuid.uuid4(), - agent_task_id=uuid.uuid4(), - initiator="user", - counterpart="primary", - context_id=context_id, - trace_id=None, - topic=None, - metadata={}, - started_at=now, - ended_at=None, - ) - - # ───────────────────────────────────────────────────────────────────────────── # Registry / dispatch # ───────────────────────────────────────────────────────────────────────────── @@ -77,7 +40,8 @@ def test_registers_all_default_processors(self) -> None: def test_duplicate_registration_raises(self) -> None: class DupeProc(EventProcessor): event_type = SessionStartEvent - async def process(self, event, db) -> None: ... + + async def process(self, event, db) -> None: ... # noqa: ARG002 with pytest.raises(ValueError, match="duplicate"): EventHandler( @@ -91,120 +55,49 @@ async def test_unknown_event_type_skip(self) -> None: context_id="c", initiator="user", counterpart="primary", reason="completed", ) - await h.handle(ev) # 예외 없이 끝나야 함 + await h.handle(ev) # 예외 없이 끝나야 함 # ───────────────────────────────────────────────────────────────────────────── -# SessionStartProcessor +# Stub processors (PR 2 에서 본 처리 로직 재작성) # ───────────────────────────────────────────────────────────────────────────── -class TestSessionStartProcessor: - @pytest.mark.asyncio - async def test_creates_fallback_task_when_missing(self) -> None: - db = MagicMock() - db.agent_session_find_by_context = AsyncMock(return_value=None) - db.agent_task_create = AsyncMock(return_value=_fake_task()) - db.agent_session_create = AsyncMock(return_value=_fake_session()) - handler = _make_handler(db) - - await handler.handle(SessionStartEvent( - context_id="ctx-1", initiator="user", counterpart="primary", - )) - - db.agent_session_find_by_context.assert_awaited_once_with("ctx-1") - db.agent_task_create.assert_awaited_once() - db.agent_session_create.assert_awaited_once() +class TestProcessorStubs: + """본 PR 의 processors 는 모두 no-op (warn-log only). DB 호출 안 함.""" @pytest.mark.asyncio - async def test_skip_when_session_exists(self) -> None: + async def test_session_start_is_noop(self) -> None: + from chronicler.processors.session_start import SessionStartProcessor db = MagicMock() - db.agent_session_find_by_context = AsyncMock(return_value=_fake_session()) - db.agent_task_create = AsyncMock() - db.agent_session_create = AsyncMock() - handler = _make_handler(db) - - await handler.handle(SessionStartEvent( - context_id="ctx-1", initiator="user", counterpart="primary", - )) - - db.agent_session_find_by_context.assert_awaited_once() - db.agent_task_create.assert_not_awaited() - db.agent_session_create.assert_not_awaited() - - @pytest.mark.asyncio - async def test_uses_provided_task_id(self) -> None: - task_id = uuid.uuid4() - db = MagicMock() - db.agent_session_find_by_context = AsyncMock(return_value=None) - db.agent_task_create = AsyncMock() - db.agent_session_create = AsyncMock(return_value=_fake_session()) - handler = _make_handler(db) - - await handler.handle(SessionStartEvent( - context_id="ctx-1", - initiator="user", counterpart="primary", - agent_task_id=task_id, - )) - - db.agent_task_create.assert_not_awaited() - # session_create 의 doc.agent_task_id 가 전달한 task_id - session_doc = db.agent_session_create.call_args.args[0] - assert session_doc.agent_task_id == task_id - - -# ───────────────────────────────────────────────────────────────────────────── -# ItemAppendProcessor -# ───────────────────────────────────────────────────────────────────────────── - + proc = SessionStartProcessor() + ev = SessionStartEvent( + context_id="c", initiator="user", counterpart="primary", + ) + await proc.process(ev, db) # 예외 없이 + # DB call 없음 — stub 이라 어떤 메서드도 안 부름 + assert not db.method_calls -class TestItemAppendProcessor: @pytest.mark.asyncio - async def test_skip_duplicate_message_id(self) -> None: - session = _fake_session() - # message_id dedup — list 가 1건 반환 → create 호출 안 됨 - existing_item = MagicMock() - existing_item.message_id = "m1" - + async def test_item_append_is_noop(self) -> None: + from chronicler.processors.item_append import ItemAppendProcessor db = MagicMock() - db.agent_session_find_by_context = AsyncMock(return_value=session) - db.agent_item_list = AsyncMock(return_value=[existing_item]) - db.agent_item_create = AsyncMock() - handler = _make_handler(db) - - await handler.handle(ItemAppendEvent( + proc = ItemAppendProcessor() + ev = ItemAppendEvent( context_id="c", initiator="user", counterpart="primary", - role="user", sender="user", - content={"text": "hi"}, message_id="m1", - )) - - db.agent_item_create.assert_not_awaited() - - -# ───────────────────────────────────────────────────────────────────────────── -# SessionEndProcessor -# ───────────────────────────────────────────────────────────────────────────── - + role="user", sender="user", content=[], + ) + await proc.process(ev, db) + assert not db.method_calls -class TestSessionEndProcessor: @pytest.mark.asyncio - async def test_updates_ended_at(self) -> None: - session = _fake_session() + async def test_session_end_is_noop(self) -> None: + from chronicler.processors.session_end import SessionEndProcessor db = MagicMock() - db.agent_session_find_by_context = AsyncMock(return_value=session) - db.agent_session_update = AsyncMock(return_value=session) - handler = _make_handler(db) - - await handler.handle(SessionEndEvent( + proc = SessionEndProcessor() + ev = SessionEndEvent( context_id="c", initiator="user", counterpart="primary", - reason="completed", duration_ms=42, - )) - - db.agent_session_update.assert_awaited_once() - # update 호출 인자: (session_id, AgentSessionUpdate(...)) - call_args = db.agent_session_update.call_args - assert call_args.args[0] == session.id - patch = call_args.args[1] - assert patch.ended_at is not None - assert patch.metadata["end_reason"] == "completed" - assert patch.metadata["duration_ms"] == 42 + reason="completed", + ) + await proc.process(ev, db) + assert not db.method_calls diff --git a/mcp/doc-store/CLAUDE.md b/mcp/doc-store/CLAUDE.md index 76deafe..f3a0ec9 100644 --- a/mcp/doc-store/CLAUDE.md +++ b/mcp/doc-store/CLAUDE.md @@ -19,20 +19,39 @@ PostgreSQL `dev_team` DB 의 thin CRUD 래퍼. **비즈니스 로직 없음** --- -## 2. Collection 5종 — 절대 추가 / 변경은 별 이슈 +## 2. Collection 10종 — 절대 추가 / 변경은 별도 이슈 + +#75 재설계로 chat tier (UG↔P/A) + A2A tier (에이전트 간) + 도메인 산출물 분리. + +### Chat tier + +| Collection | 정체성 | +|---|---| +| `sessions` | UG↔P/A 의 한 대화창 | +| `chats` | session 안의 한 발화 (immutable) | +| `assignments` | chat 중 합의된 도메인 work item (P/A 발급) | + +### A2A tier + +| Collection | 정체성 | +|---|---| +| `a2a_contexts` | 두 에이전트 사이 대화 namespace (A2A wire `contextId` 와 1:1) | +| `a2a_messages` | A2A `Message` (immutable) — trivial 또는 Task.history | +| `a2a_tasks` | A2A `Task` (stateful work, SUBMITTED → COMPLETED 등) | +| `a2a_task_status_updates` | Task state transition 로그 (immutable) | +| `a2a_task_artifacts` | Task 산출물 (immutable) | + +### 도메인 산출물 | Collection | 정체성 | |---|---| -| `agent_tasks` | 작업 단위 (Chronicler "Task") | -| `agent_sessions` | 한 task 안의 대화 흐름 | -| `agent_items` | 한 session 안의 개별 메시지 | | `issues` | 외부 이슈 트래커 mirror (Epic/Story/Task type) | | `wiki_pages` | 위키 문서 mirror (PRD/ADR/business_rule 등 type) | **원칙**: -- 새 collection 추가 = **별 이슈 + 사용자 컨펌** 후. 즉흥 추가 금지. +- 새 collection 추가 = **별도 이슈 + 사용자 컨펌** 후. 즉흥 추가 금지. - 기존 collection 의 컬럼 변경 = **마이그레이션 신규 파일 작성** (기존 SQL 수정 X). -- PRD 는 별 entity 안 만든다. `wiki_pages.page_type='prd'` 로 통합. +- PRD 는 별도 entity 안 만든다. `wiki_pages.page_type='prd'` 로 통합. --- @@ -63,12 +82,18 @@ PostgreSQL `dev_team` DB 의 thin CRUD 래퍼. **비즈니스 로직 없음** | `{collection}.delete` | `(id) → bool` | hard delete | | `{collection}.count` | `(where?) → int` | 페이지네이션용 | -`agent_items` 는 immutable → `update` 미노출 (5 op). - -특수 도구 (Chronicler 의 read 패턴 전용): -- `agent_items.list_by_session(session_id) → ordered list` (prev_item_id 체인) -- `agent_sessions.list_by_task(agent_task_id) → list` -- `agent_sessions.find_by_context(context_id) → Session | null` +immutable collections (`chats`, `a2a_messages`, `a2a_task_status_updates`, +`a2a_task_artifacts`) 는 `update` 미노출 (5 op + special). + +특수 도구 (read 패턴 전용): +- `chats.list_by_session(session_id) → ordered list` +- `assignments.list_by_session(root_session_id) → list` +- `a2a_contexts.find_by_context_id(context_id) → A2AContext | null` +- `a2a_messages.list_by_context(a2a_context_id) → ordered list` +- `a2a_messages.list_by_task(a2a_task_id) → ordered list` (Task.history) +- `a2a_tasks.find_by_task_id(task_id) → A2ATask | null` +- `a2a_task_status_updates.list_by_task(a2a_task_id) → ordered list` +- `a2a_task_artifacts.list_by_task(a2a_task_id) → ordered list` --- diff --git a/mcp/doc-store/migrations/004_redesign_chat_a2a.rollback.sql b/mcp/doc-store/migrations/004_redesign_chat_a2a.rollback.sql new file mode 100644 index 0000000..567cf6f --- /dev/null +++ b/mcp/doc-store/migrations/004_redesign_chat_a2a.rollback.sql @@ -0,0 +1,84 @@ +-- 004_redesign_chat_a2a.rollback.sql +-- 새 8 테이블 폐기 → 기존 3 테이블 (agent_tasks / sessions / items) 재생성. +-- assignment_id 컬럼은 다시 agent_task_id 로. 기존 데이터는 복원 불가 (cut-over). + +-- 1. issues / wiki_pages 의 assignment_id FK 제거 + 컬럼명 복귀 + +ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_assignment_id_fkey; +ALTER TABLE wiki_pages DROP CONSTRAINT IF EXISTS wiki_pages_assignment_id_fkey; + +ALTER TABLE issues RENAME COLUMN assignment_id TO agent_task_id; +ALTER TABLE wiki_pages RENAME COLUMN assignment_id TO agent_task_id; + +ALTER INDEX idx_issues_assignment RENAME TO idx_issues_task; +ALTER INDEX idx_wiki_pages_assignment RENAME TO idx_wiki_pages_task; + +-- 2. 새 8 테이블 폐기 + +DROP TABLE IF EXISTS a2a_task_artifacts CASCADE; +DROP TABLE IF EXISTS a2a_task_status_updates CASCADE; +DROP TABLE IF EXISTS a2a_messages CASCADE; +DROP TABLE IF EXISTS a2a_tasks CASCADE; +DROP TABLE IF EXISTS a2a_contexts CASCADE; +DROP TABLE IF EXISTS chats CASCADE; +DROP TABLE IF EXISTS assignments CASCADE; +DROP TABLE IF EXISTS sessions CASCADE; + +-- 3. 기존 3 테이블 재생성 (001_chronicler.sql 의 forward 와 동일) + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE agent_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'done', 'cancelled')), + owner_agent TEXT, + issue_refs UUID[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_agent_tasks_status ON agent_tasks (status); +CREATE INDEX idx_agent_tasks_owner ON agent_tasks (owner_agent); +CREATE INDEX idx_agent_tasks_issues ON agent_tasks USING GIN (issue_refs); + +CREATE TABLE agent_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_task_id UUID NOT NULL REFERENCES agent_tasks(id) ON DELETE CASCADE, + initiator TEXT NOT NULL, + counterpart TEXT NOT NULL, + context_id TEXT NOT NULL, + trace_id TEXT, + topic TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ +); +CREATE INDEX idx_agent_sessions_task ON agent_sessions (agent_task_id, started_at); +CREATE INDEX idx_agent_sessions_context ON agent_sessions (context_id); +CREATE INDEX idx_agent_sessions_trace ON agent_sessions (trace_id); + +CREATE TABLE agent_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + prev_item_id UUID REFERENCES agent_items(id), + role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')), + sender TEXT NOT NULL, + content JSONB NOT NULL, + message_id TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_agent_items_session ON agent_items (agent_session_id, created_at); +CREATE INDEX idx_agent_items_prev ON agent_items (prev_item_id); + +-- 4. issues / wiki_pages 의 FK 복원 + +ALTER TABLE issues + ADD CONSTRAINT issues_agent_task_id_fkey + FOREIGN KEY (agent_task_id) REFERENCES agent_tasks(id) ON DELETE SET NULL; +ALTER TABLE wiki_pages + ADD CONSTRAINT wiki_pages_agent_task_id_fkey + FOREIGN KEY (agent_task_id) REFERENCES agent_tasks(id) ON DELETE SET NULL; diff --git a/mcp/doc-store/migrations/004_redesign_chat_a2a.sql b/mcp/doc-store/migrations/004_redesign_chat_a2a.sql new file mode 100644 index 0000000..02acb9c --- /dev/null +++ b/mcp/doc-store/migrations/004_redesign_chat_a2a.sql @@ -0,0 +1,170 @@ +-- 004_redesign_chat_a2a.sql — forward +-- #75: chat tier (sessions / chats / assignments) + A2A tier (a2a_contexts / +-- a2a_messages / a2a_tasks / a2a_task_status_updates / a2a_task_artifacts) +-- 분리. 기존 agent_tasks / agent_sessions / agent_items 폐기. +-- cut-over — 마이그레이션 path 없음 (기존 데이터 폐기). +-- 참조: docs/proposal/knowledge-model.md §4.2, +-- docs/proposal/architecture-chat-protocol.md + +-- ───────────────────────────────────────────────────────────────────────────── +-- 1. 기존 3 테이블 폐기 + issues / wiki_pages 의 FK 정리 +-- ───────────────────────────────────────────────────────────────────────────── + +ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_agent_task_id_fkey; +ALTER TABLE wiki_pages DROP CONSTRAINT IF EXISTS wiki_pages_agent_task_id_fkey; + +DROP TABLE IF EXISTS agent_items CASCADE; +DROP TABLE IF EXISTS agent_sessions CASCADE; +DROP TABLE IF EXISTS agent_tasks CASCADE; + +-- 2. issues / wiki_pages 의 agent_task_id → assignment_id 로 의미 재정의 + +ALTER TABLE issues RENAME COLUMN agent_task_id TO assignment_id; +ALTER TABLE wiki_pages RENAME COLUMN agent_task_id TO assignment_id; + +ALTER INDEX idx_issues_task RENAME TO idx_issues_assignment; +ALTER INDEX idx_wiki_pages_task RENAME TO idx_wiki_pages_assignment; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 3. Chat tier — sessions / chats / assignments +-- ───────────────────────────────────────────────────────────────────────────── + +-- 한 대화창 단위 (UG↔P/A). server-side 영속, FE 는 active session_id reference 만. +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_endpoint TEXT NOT NULL, -- 'primary' | 'architect' + initiator TEXT NOT NULL, -- 'user' + counterpart TEXT NOT NULL, -- agent name + metadata JSONB NOT NULL DEFAULT '{}', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ +); +CREATE INDEX idx_sessions_endpoint ON sessions (agent_endpoint, started_at); + +-- session 안의 한 발화. prev_chat_id 로 시간순 chain. +CREATE TABLE chats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + prev_chat_id UUID REFERENCES chats(id), + role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')), + sender TEXT NOT NULL, + content JSONB NOT NULL, -- A2A parts 형태 + message_id TEXT, -- FE 또는 server 발급 + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_chats_session ON chats (session_id, created_at); +CREATE INDEX idx_chats_prev ON chats (prev_chat_id); + +-- 도메인 work item. P/A 가 chat 중 합의해 발급. 한 Assignment 안에 여러 A2A Task 발생 가능. +CREATE TABLE assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'done', 'cancelled')), + owner_agent TEXT, + root_session_id UUID REFERENCES sessions(id) ON DELETE SET NULL, + issue_refs UUID[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_assignments_status ON assignments (status); +CREATE INDEX idx_assignments_owner ON assignments (owner_agent); +CREATE INDEX idx_assignments_root_session ON assignments (root_session_id); +CREATE INDEX idx_assignments_issue_refs ON assignments USING GIN (issue_refs); + +-- issues / wiki_pages 의 assignment_id 가 이제 assignments(id) 를 가리킴 +ALTER TABLE issues + ADD CONSTRAINT issues_assignment_id_fkey + FOREIGN KEY (assignment_id) REFERENCES assignments(id) ON DELETE SET NULL; +ALTER TABLE wiki_pages + ADD CONSTRAINT wiki_pages_assignment_id_fkey + FOREIGN KEY (assignment_id) REFERENCES assignments(id) ON DELETE SET NULL; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 4. A2A tier — a2a_contexts / a2a_messages / a2a_tasks +-- + a2a_task_status_updates / a2a_task_artifacts +-- ───────────────────────────────────────────────────────────────────────────── + +-- 두 에이전트 사이 대화 namespace. session 발 / assignment 발 / standalone (system trigger). +CREATE TABLE a2a_contexts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + context_id TEXT NOT NULL, -- A2A wire contextId + initiator_agent TEXT NOT NULL, + counterpart_agent TEXT NOT NULL, + parent_session_id UUID REFERENCES sessions(id) ON DELETE SET NULL, + parent_assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL, + trace_id TEXT, + topic TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ +); +CREATE INDEX idx_a2a_contexts_context ON a2a_contexts (context_id); +CREATE INDEX idx_a2a_contexts_trace ON a2a_contexts (trace_id); +CREATE INDEX idx_a2a_contexts_parent_session ON a2a_contexts (parent_session_id); +CREATE INDEX idx_a2a_contexts_parent_assignment ON a2a_contexts (parent_assignment_id); + +-- A2A Task — stateful long-running work tracking. SUBMITTED → COMPLETED. +CREATE TABLE a2a_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id TEXT NOT NULL, -- A2A wire taskId + a2a_context_id UUID NOT NULL REFERENCES a2a_contexts(id) ON DELETE CASCADE, + state TEXT NOT NULL + CHECK (state IN ('SUBMITTED', 'WORKING', 'COMPLETED', + 'INPUT_REQUIRED', 'FAILED')), + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}' +); +CREATE INDEX idx_a2a_tasks_context ON a2a_tasks (a2a_context_id, submitted_at); +CREATE INDEX idx_a2a_tasks_state ON a2a_tasks (state); +CREATE INDEX idx_a2a_tasks_assignment ON a2a_tasks (assignment_id); +CREATE INDEX idx_a2a_tasks_task_id ON a2a_tasks (task_id); + +-- A2A Message — trivial Message (a2a_task_id NULL) 또는 Task.history (a2a_task_id 채움). +CREATE TABLE a2a_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id TEXT NOT NULL, -- A2A wire messageId + a2a_context_id UUID NOT NULL REFERENCES a2a_contexts(id) ON DELETE CASCADE, + a2a_task_id UUID REFERENCES a2a_tasks(id) ON DELETE SET NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')), + sender TEXT NOT NULL, + parts JSONB NOT NULL, + prev_message_id UUID REFERENCES a2a_messages(id), + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_a2a_messages_context ON a2a_messages (a2a_context_id, created_at); +CREATE INDEX idx_a2a_messages_task ON a2a_messages (a2a_task_id); +CREATE INDEX idx_a2a_messages_message_id ON a2a_messages (message_id); +CREATE INDEX idx_a2a_messages_prev ON a2a_messages (prev_message_id); + +-- A2A Task 의 state transition 로그. +CREATE TABLE a2a_task_status_updates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + a2a_task_id UUID NOT NULL REFERENCES a2a_tasks(id) ON DELETE CASCADE, + state TEXT NOT NULL + CHECK (state IN ('SUBMITTED', 'WORKING', 'COMPLETED', + 'INPUT_REQUIRED', 'FAILED')), + transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reason TEXT, + metadata JSONB NOT NULL DEFAULT '{}' +); +CREATE INDEX idx_a2a_task_status_updates_task + ON a2a_task_status_updates (a2a_task_id, transitioned_at); + +-- A2A Task 의 산출물 (Artifact). +CREATE TABLE a2a_task_artifacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + a2a_task_id UUID NOT NULL REFERENCES a2a_tasks(id) ON DELETE CASCADE, + artifact_id TEXT NOT NULL, -- A2A wire artifactId + name TEXT, + parts JSONB NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_a2a_task_artifacts_task ON a2a_task_artifacts (a2a_task_id); diff --git a/mcp/doc-store/src/doc_store_mcp/__init__.py b/mcp/doc-store/src/doc_store_mcp/__init__.py index 8553da4..649aff8 100644 --- a/mcp/doc-store/src/doc_store_mcp/__init__.py +++ b/mcp/doc-store/src/doc_store_mcp/__init__.py @@ -1,7 +1,9 @@ -"""Document DB MCP — PostgreSQL JSONB CRUD over streamable HTTP. +"""Doc Store MCP — PostgreSQL CRUD over streamable HTTP. -`dev_team` DB 의 5 collections (agent_tasks / agent_sessions / agent_items / -issues / wiki_pages) 에 대한 thin CRUD 래퍼. 비즈니스 로직 없음. +`dev_team` DB 의 10 collections — chat tier (sessions / chats / assignments) + +A2A tier (a2a_contexts / a2a_messages / a2a_tasks / a2a_task_status_updates / +a2a_task_artifacts) + 도메인 산출물 (issues / wiki_pages) — 에 대한 thin CRUD +래퍼. 비즈니스 로직 없음 (#75 재설계). 자세한 규약은 모듈 루트의 `CLAUDE.md` 참조. """ diff --git a/mcp/doc-store/src/doc_store_mcp/mcp_instance.py b/mcp/doc-store/src/doc_store_mcp/mcp_instance.py index 146dc1c..3962f39 100644 --- a/mcp/doc-store/src/doc_store_mcp/mcp_instance.py +++ b/mcp/doc-store/src/doc_store_mcp/mcp_instance.py @@ -3,6 +3,10 @@ 본 모듈이 `mcp` 인스턴스를 노출. tools/ 의 각 모듈이 본 인스턴스를 import 해 `@mcp.tool()` 으로 도구 등록. lifespan 은 Repository 들을 만들어 AppContext 로 yield → 도구는 `ctx.request_context.lifespan_context.X` 로 접근. + +#75 재설계: 10 collections — chat tier (sessions / chats / assignments) + A2A +tier (a2a_contexts / a2a_messages / a2a_tasks / a2a_task_status_updates / +a2a_task_artifacts) + 도메인 산출물 (issues / wiki_pages). """ from __future__ import annotations @@ -19,10 +23,15 @@ from doc_store_mcp.config import Settings from doc_store_mcp.db import apply_migrations, pool_lifespan from doc_store_mcp.repositories import ( - AgentItemRepository, - AgentSessionRepository, - AgentTaskRepository, + A2AContextRepository, + A2AMessageRepository, + A2ATaskArtifactRepository, + A2ATaskRepository, + A2ATaskStatusUpdateRepository, + AssignmentRepository, + ChatRepository, IssueRepository, + SessionRepository, WikiPageRepository, ) @@ -33,9 +42,17 @@ class AppContext: """lifespan 이 yield 하는 의존성 묶음. 도구는 본 객체로부터 repository 를 꺼내 씀.""" - agent_task: AgentTaskRepository - agent_session: AgentSessionRepository - agent_item: AgentItemRepository + # Chat tier + session: SessionRepository + chat: ChatRepository + assignment: AssignmentRepository + # A2A tier + a2a_context: A2AContextRepository + a2a_message: A2AMessageRepository + a2a_task: A2ATaskRepository + a2a_task_status_update: A2ATaskStatusUpdateRepository + a2a_task_artifact: A2ATaskArtifactRepository + # 도메인 산출물 issue: IssueRepository wiki_page: WikiPageRepository @@ -54,13 +71,18 @@ async def _app_lifespan(_server: FastMCP) -> AsyncIterator[AppContext]: max_size=settings.pool_max_size, ) as pool: ctx = AppContext( - agent_task=AgentTaskRepository(pool), - agent_session=AgentSessionRepository(pool), - agent_item=AgentItemRepository(pool), + session=SessionRepository(pool), + chat=ChatRepository(pool), + assignment=AssignmentRepository(pool), + a2a_context=A2AContextRepository(pool), + a2a_message=A2AMessageRepository(pool), + a2a_task=A2ATaskRepository(pool), + a2a_task_status_update=A2ATaskStatusUpdateRepository(pool), + a2a_task_artifact=A2ATaskArtifactRepository(pool), issue=IssueRepository(pool), wiki_page=WikiPageRepository(pool), ) - logger.info("doc-store-mcp ready (5 collections)") + logger.info("doc-store-mcp ready (10 collections — chat / a2a / 도메인)") yield ctx diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/__init__.py b/mcp/doc-store/src/doc_store_mcp/repositories/__init__.py index 4faef43..168d7e7 100644 --- a/mcp/doc-store/src/doc_store_mcp/repositories/__init__.py +++ b/mcp/doc-store/src/doc_store_mcp/repositories/__init__.py @@ -1,23 +1,35 @@ """Repository 레이어 — collection 별 CRUD 구현.""" -from doc_store_mcp.repositories.agent_item import AgentItemRepository -from doc_store_mcp.repositories.agent_session import AgentSessionRepository -from doc_store_mcp.repositories.agent_task import AgentTaskRepository +from doc_store_mcp.repositories.a2a_context import A2AContextRepository +from doc_store_mcp.repositories.a2a_message import A2AMessageRepository +from doc_store_mcp.repositories.a2a_task import A2ATaskRepository +from doc_store_mcp.repositories.a2a_task_artifact import A2ATaskArtifactRepository +from doc_store_mcp.repositories.a2a_task_status_update import ( + A2ATaskStatusUpdateRepository, +) +from doc_store_mcp.repositories.assignment import AssignmentRepository from doc_store_mcp.repositories.base import ( AbstractRepository, ListFilter, PostgresRepositoryBase, ) +from doc_store_mcp.repositories.chat import ChatRepository from doc_store_mcp.repositories.issue import IssueRepository +from doc_store_mcp.repositories.session import SessionRepository from doc_store_mcp.repositories.wiki_page import WikiPageRepository __all__ = [ + "A2AContextRepository", + "A2AMessageRepository", + "A2ATaskArtifactRepository", + "A2ATaskRepository", + "A2ATaskStatusUpdateRepository", "AbstractRepository", - "AgentItemRepository", - "AgentSessionRepository", - "AgentTaskRepository", + "AssignmentRepository", + "ChatRepository", "IssueRepository", "ListFilter", "PostgresRepositoryBase", + "SessionRepository", "WikiPageRepository", ] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/agent_session.py b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_context.py similarity index 50% rename from mcp/doc-store/src/doc_store_mcp/repositories/agent_session.py rename to mcp/doc-store/src/doc_store_mcp/repositories/a2a_context.py index c93e143..c97396e 100644 --- a/mcp/doc-store/src/doc_store_mcp/repositories/agent_session.py +++ b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_context.py @@ -1,4 +1,4 @@ -"""AgentSessionRepository.""" +"""A2AContextRepository — A2A tier 의 a2a_contexts CRUD.""" from __future__ import annotations @@ -7,40 +7,43 @@ import asyncpg -from doc_store_mcp.repositories.base import PostgresRepositoryBase -from dev_team_shared.doc_store.schemas.agent_session import ( - AgentSessionCreate, - AgentSessionRead, - AgentSessionUpdate, +from dev_team_shared.doc_store.schemas.a2a_context import ( + A2AContextCreate, + A2AContextRead, + A2AContextUpdate, ) +from doc_store_mcp.repositories.base import PostgresRepositoryBase -class AgentSessionRepository( - PostgresRepositoryBase[AgentSessionCreate, AgentSessionUpdate, AgentSessionRead], +class A2AContextRepository( + PostgresRepositoryBase[A2AContextCreate, A2AContextUpdate, A2AContextRead], ): @property def collection_name(self) -> str: - return "agent_sessions" + return "a2a_contexts" - def _to_read(self, row: asyncpg.Record) -> AgentSessionRead: + def _to_read(self, row: asyncpg.Record) -> A2AContextRead: d = dict(row) if isinstance(d.get("metadata"), str): d["metadata"] = json.loads(d["metadata"]) - return AgentSessionRead.model_validate(d) + return A2AContextRead.model_validate(d) - async def create(self, doc: AgentSessionCreate) -> AgentSessionRead: + async def create(self, doc: A2AContextCreate) -> A2AContextRead: sql = """ - INSERT INTO agent_sessions - (agent_task_id, initiator, counterpart, context_id, trace_id, topic, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + INSERT INTO a2a_contexts + (context_id, initiator_agent, counterpart_agent, + parent_session_id, parent_assignment_id, + trace_id, topic, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) RETURNING * """ row = await self._pool.fetchrow( sql, - doc.agent_task_id, - doc.initiator, - doc.counterpart, doc.context_id, + doc.initiator_agent, + doc.counterpart_agent, + doc.parent_session_id, + doc.parent_assignment_id, doc.trace_id, doc.topic, self._to_jsonb(doc.metadata), @@ -48,7 +51,9 @@ async def create(self, doc: AgentSessionCreate) -> AgentSessionRead: assert row is not None return self._to_read(row) - async def update(self, id: UUID, patch: AgentSessionUpdate) -> AgentSessionRead | None: + async def update( + self, id: UUID, patch: A2AContextUpdate, + ) -> A2AContextRead | None: fields = patch.model_dump(exclude_unset=True) if not fields: return await self.get(id) @@ -62,7 +67,7 @@ async def update(self, id: UUID, patch: AgentSessionUpdate) -> AgentSessionRead set_clauses.append(f"{col} = ${i}") params.append(val) sql = ( - f"UPDATE agent_sessions SET {', '.join(set_clauses)} " + f"UPDATE a2a_contexts SET {', '.join(set_clauses)} " f"WHERE id = ${len(params) + 1} RETURNING *" ) params.append(id) @@ -71,21 +76,16 @@ async def update(self, id: UUID, patch: AgentSessionUpdate) -> AgentSessionRead # ---- 특수 쿼리 ---- - async def list_by_task(self, agent_task_id: UUID) -> list[AgentSessionRead]: - rows = await self._pool.fetch( - "SELECT * FROM agent_sessions WHERE agent_task_id = $1 ORDER BY started_at", - agent_task_id, - ) - return [self._to_read(r) for r in rows] - - async def find_by_context(self, context_id: str) -> AgentSessionRead | None: - """가장 최근 session 1건 (같은 contextId 가 여러 turn 에 걸칠 수 있음).""" + async def find_by_context_id( + self, context_id: str, + ) -> A2AContextRead | None: + """가장 최근 1건 (같은 wire contextId 가 여러 번 등장 가능).""" row = await self._pool.fetchrow( - "SELECT * FROM agent_sessions WHERE context_id = $1 " + "SELECT * FROM a2a_contexts WHERE context_id = $1 " "ORDER BY started_at DESC LIMIT 1", context_id, ) return self._to_read(row) if row else None -__all__ = ["AgentSessionRepository"] +__all__ = ["A2AContextRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/a2a_message.py b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_message.py new file mode 100644 index 0000000..9afe339 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_message.py @@ -0,0 +1,78 @@ +"""A2AMessageRepository — A2A tier 의 a2a_messages CRUD. immutable (no update).""" + +from __future__ import annotations + +import json +from uuid import UUID + +import asyncpg +from pydantic import BaseModel + +from dev_team_shared.doc_store.schemas.a2a_message import ( + A2AMessageCreate, + A2AMessageRead, +) +from doc_store_mcp.repositories.base import PostgresRepositoryBase + + +class A2AMessageRepository( + PostgresRepositoryBase[A2AMessageCreate, BaseModel, A2AMessageRead], +): + """a2a_messages 는 immutable. update 미지원.""" + + @property + def collection_name(self) -> str: + return "a2a_messages" + + def _to_read(self, row: asyncpg.Record) -> A2AMessageRead: + d = dict(row) + for col in ("parts", "metadata"): + if isinstance(d.get(col), str): + d[col] = json.loads(d[col]) + return A2AMessageRead.model_validate(d) + + async def create(self, doc: A2AMessageCreate) -> A2AMessageRead: + sql = """ + INSERT INTO a2a_messages + (message_id, a2a_context_id, a2a_task_id, role, sender, + parts, prev_message_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb) + RETURNING * + """ + row = await self._pool.fetchrow( + sql, + doc.message_id, + doc.a2a_context_id, + doc.a2a_task_id, + doc.role, + doc.sender, + self._to_jsonb(doc.parts), + doc.prev_message_id, + self._to_jsonb(doc.metadata), + ) + assert row is not None + return self._to_read(row) + + async def update(self, id: UUID, patch: BaseModel) -> A2AMessageRead | None: # noqa: ARG002 + raise NotImplementedError("a2a_messages are immutable") + + # ---- 특수 쿼리 ---- + + async def list_by_context(self, a2a_context_id: UUID) -> list[A2AMessageRead]: + rows = await self._pool.fetch( + "SELECT * FROM a2a_messages WHERE a2a_context_id = $1 " + "ORDER BY created_at", + a2a_context_id, + ) + return [self._to_read(r) for r in rows] + + async def list_by_task(self, a2a_task_id: UUID) -> list[A2AMessageRead]: + rows = await self._pool.fetch( + "SELECT * FROM a2a_messages WHERE a2a_task_id = $1 " + "ORDER BY created_at", + a2a_task_id, + ) + return [self._to_read(r) for r in rows] + + +__all__ = ["A2AMessageRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task.py b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task.py new file mode 100644 index 0000000..ce6ff40 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task.py @@ -0,0 +1,84 @@ +"""A2ATaskRepository — A2A tier 의 a2a_tasks CRUD.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import asyncpg + +from dev_team_shared.doc_store.schemas.a2a_task import ( + A2ATaskCreate, + A2ATaskRead, + A2ATaskUpdate, +) +from doc_store_mcp.repositories.base import PostgresRepositoryBase + + +class A2ATaskRepository( + PostgresRepositoryBase[A2ATaskCreate, A2ATaskUpdate, A2ATaskRead], +): + @property + def collection_name(self) -> str: + return "a2a_tasks" + + def _to_read(self, row: asyncpg.Record) -> A2ATaskRead: + d = dict(row) + if isinstance(d.get("metadata"), str): + d["metadata"] = json.loads(d["metadata"]) + return A2ATaskRead.model_validate(d) + + async def create(self, doc: A2ATaskCreate) -> A2ATaskRead: + sql = """ + INSERT INTO a2a_tasks + (task_id, a2a_context_id, state, assignment_id, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb) + RETURNING * + """ + row = await self._pool.fetchrow( + sql, + doc.task_id, + doc.a2a_context_id, + doc.state, + doc.assignment_id, + self._to_jsonb(doc.metadata), + ) + assert row is not None + return self._to_read(row) + + async def update( + self, id: UUID, patch: A2ATaskUpdate, + ) -> A2ATaskRead | None: + fields = patch.model_dump(exclude_unset=True) + if not fields: + return await self.get(id) + set_clauses: list[str] = [] + params: list[object] = [] + for i, (col, val) in enumerate(fields.items(), start=1): + if col == "metadata": + set_clauses.append(f"{col} = ${i}::jsonb") + params.append(self._to_jsonb(val)) + else: + set_clauses.append(f"{col} = ${i}") + params.append(val) + sql = ( + f"UPDATE a2a_tasks SET {', '.join(set_clauses)} " + f"WHERE id = ${len(params) + 1} RETURNING *" + ) + params.append(id) + row = await self._pool.fetchrow(sql, *params) + return self._to_read(row) if row else None + + # ---- 특수 쿼리 ---- + + async def find_by_task_id(self, task_id: str) -> A2ATaskRead | None: + """가장 최근 1건 (같은 wire taskId 가 여러 번 등장 가능).""" + row = await self._pool.fetchrow( + "SELECT * FROM a2a_tasks WHERE task_id = $1 " + "ORDER BY submitted_at DESC LIMIT 1", + task_id, + ) + return self._to_read(row) if row else None + + +__all__ = ["A2ATaskRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task_artifact.py b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task_artifact.py new file mode 100644 index 0000000..e3f97e6 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task_artifact.py @@ -0,0 +1,72 @@ +"""A2ATaskArtifactRepository — A2A Task 산출물. immutable.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import asyncpg +from pydantic import BaseModel + +from dev_team_shared.doc_store.schemas.a2a_task_artifact import ( + A2ATaskArtifactCreate, + A2ATaskArtifactRead, +) +from doc_store_mcp.repositories.base import PostgresRepositoryBase + + +class A2ATaskArtifactRepository( + PostgresRepositoryBase[A2ATaskArtifactCreate, BaseModel, A2ATaskArtifactRead], +): + """immutable. update 미지원.""" + + @property + def collection_name(self) -> str: + return "a2a_task_artifacts" + + def _to_read(self, row: asyncpg.Record) -> A2ATaskArtifactRead: + d = dict(row) + for col in ("parts", "metadata"): + if isinstance(d.get(col), str): + d[col] = json.loads(d[col]) + return A2ATaskArtifactRead.model_validate(d) + + async def create( + self, doc: A2ATaskArtifactCreate, + ) -> A2ATaskArtifactRead: + sql = """ + INSERT INTO a2a_task_artifacts + (a2a_task_id, artifact_id, name, parts, metadata) + VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) + RETURNING * + """ + row = await self._pool.fetchrow( + sql, + doc.a2a_task_id, + doc.artifact_id, + doc.name, + self._to_jsonb(doc.parts), + self._to_jsonb(doc.metadata), + ) + assert row is not None + return self._to_read(row) + + async def update( + self, id: UUID, patch: BaseModel, # noqa: ARG002 + ) -> A2ATaskArtifactRead | None: + raise NotImplementedError("a2a_task_artifacts are immutable") + + # ---- 특수 쿼리 ---- + + async def list_by_task( + self, a2a_task_id: UUID, + ) -> list[A2ATaskArtifactRead]: + rows = await self._pool.fetch( + "SELECT * FROM a2a_task_artifacts WHERE a2a_task_id = $1 " + "ORDER BY created_at", + a2a_task_id, + ) + return [self._to_read(r) for r in rows] + + +__all__ = ["A2ATaskArtifactRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task_status_update.py b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task_status_update.py new file mode 100644 index 0000000..5559754 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/repositories/a2a_task_status_update.py @@ -0,0 +1,72 @@ +"""A2ATaskStatusUpdateRepository — A2A Task state transition 로그. immutable.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import asyncpg +from pydantic import BaseModel + +from dev_team_shared.doc_store.schemas.a2a_task_status_update import ( + A2ATaskStatusUpdateCreate, + A2ATaskStatusUpdateRead, +) +from doc_store_mcp.repositories.base import PostgresRepositoryBase + + +class A2ATaskStatusUpdateRepository( + PostgresRepositoryBase[ + A2ATaskStatusUpdateCreate, BaseModel, A2ATaskStatusUpdateRead, + ], +): + """immutable. update 미지원.""" + + @property + def collection_name(self) -> str: + return "a2a_task_status_updates" + + def _to_read(self, row: asyncpg.Record) -> A2ATaskStatusUpdateRead: + d = dict(row) + if isinstance(d.get("metadata"), str): + d["metadata"] = json.loads(d["metadata"]) + return A2ATaskStatusUpdateRead.model_validate(d) + + async def create( + self, doc: A2ATaskStatusUpdateCreate, + ) -> A2ATaskStatusUpdateRead: + sql = """ + INSERT INTO a2a_task_status_updates + (a2a_task_id, state, reason, metadata) + VALUES ($1, $2, $3, $4::jsonb) + RETURNING * + """ + row = await self._pool.fetchrow( + sql, + doc.a2a_task_id, + doc.state, + doc.reason, + self._to_jsonb(doc.metadata), + ) + assert row is not None + return self._to_read(row) + + async def update( + self, id: UUID, patch: BaseModel, # noqa: ARG002 + ) -> A2ATaskStatusUpdateRead | None: + raise NotImplementedError("a2a_task_status_updates are immutable") + + # ---- 특수 쿼리 ---- + + async def list_by_task( + self, a2a_task_id: UUID, + ) -> list[A2ATaskStatusUpdateRead]: + rows = await self._pool.fetch( + "SELECT * FROM a2a_task_status_updates WHERE a2a_task_id = $1 " + "ORDER BY transitioned_at", + a2a_task_id, + ) + return [self._to_read(r) for r in rows] + + +__all__ = ["A2ATaskStatusUpdateRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/agent_item.py b/mcp/doc-store/src/doc_store_mcp/repositories/agent_item.py deleted file mode 100644 index 17b36bf..0000000 --- a/mcp/doc-store/src/doc_store_mcp/repositories/agent_item.py +++ /dev/null @@ -1,77 +0,0 @@ -"""AgentItemRepository — items 는 immutable. update 는 의도적으로 미지원.""" - -from __future__ import annotations - -import json -from typing import NoReturn -from uuid import UUID - -import asyncpg - -from doc_store_mcp.repositories.base import PostgresRepositoryBase -from dev_team_shared.doc_store.schemas.agent_item import AgentItemCreate, AgentItemRead - - -class _ImmutableUpdate: # placeholder type for ABC contract - pass - - -class AgentItemRepository( - PostgresRepositoryBase[AgentItemCreate, _ImmutableUpdate, AgentItemRead], -): - """대화 메시지 1건. 한 번 쓰면 변경 X (audit / Chronicler 의 영속성). - - update 는 ABC 계약상 시그니처는 있으나 호출 시 RuntimeError. tools 는 노출하지 않음. - """ - - @property - def collection_name(self) -> str: - return "agent_items" - - def _to_read(self, row: asyncpg.Record) -> AgentItemRead: - d = dict(row) - for col in ("content", "metadata"): - if isinstance(d.get(col), str): - d[col] = json.loads(d[col]) - return AgentItemRead.model_validate(d) - - async def create(self, doc: AgentItemCreate) -> AgentItemRead: - sql = """ - INSERT INTO agent_items - (agent_session_id, prev_item_id, role, sender, content, message_id, metadata) - VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb) - RETURNING * - """ - row = await self._pool.fetchrow( - sql, - doc.agent_session_id, - doc.prev_item_id, - doc.role, - doc.sender, - self._to_jsonb(doc.content), - doc.message_id, - self._to_jsonb(doc.metadata), - ) - assert row is not None - return self._to_read(row) - - async def update(self, id: UUID, patch: _ImmutableUpdate) -> NoReturn: # type: ignore[override] - raise RuntimeError( - "agent_items are immutable; update is not supported by design", - ) - - # ---- 특수 쿼리 ---- - - async def list_by_session(self, agent_session_id: UUID) -> list[AgentItemRead]: - """session 안의 모든 item 을 created_at 순으로 반환. - - prev_item_id chain 도 결국 created_at 순과 일치 (단조 증가) 라 단순 정렬로 충분. - """ - rows = await self._pool.fetch( - "SELECT * FROM agent_items WHERE agent_session_id = $1 ORDER BY created_at", - agent_session_id, - ) - return [self._to_read(r) for r in rows] - - -__all__ = ["AgentItemRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/assignment.py b/mcp/doc-store/src/doc_store_mcp/repositories/assignment.py new file mode 100644 index 0000000..830b88d --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/repositories/assignment.py @@ -0,0 +1,89 @@ +"""AssignmentRepository — chat tier 의 assignments CRUD.""" + +from __future__ import annotations + +import json +from uuid import UUID + +import asyncpg + +from dev_team_shared.doc_store.schemas.assignment import ( + AssignmentCreate, + AssignmentRead, + AssignmentUpdate, +) +from doc_store_mcp.repositories.base import PostgresRepositoryBase + + +class AssignmentRepository( + PostgresRepositoryBase[AssignmentCreate, AssignmentUpdate, AssignmentRead], +): + @property + def collection_name(self) -> str: + return "assignments" + + def _to_read(self, row: asyncpg.Record) -> AssignmentRead: + d = dict(row) + if isinstance(d.get("metadata"), str): + d["metadata"] = json.loads(d["metadata"]) + return AssignmentRead.model_validate(d) + + async def create(self, doc: AssignmentCreate) -> AssignmentRead: + sql = """ + INSERT INTO assignments + (title, description, status, owner_agent, root_session_id, + issue_refs, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + RETURNING * + """ + row = await self._pool.fetchrow( + sql, + doc.title, + doc.description, + doc.status, + doc.owner_agent, + doc.root_session_id, + doc.issue_refs, + self._to_jsonb(doc.metadata), + ) + assert row is not None + return self._to_read(row) + + async def update( + self, id: UUID, patch: AssignmentUpdate, + ) -> AssignmentRead | None: + fields = patch.model_dump(exclude_unset=True) + if not fields: + return await self.get(id) + set_clauses: list[str] = [] + params: list[object] = [] + for i, (col, val) in enumerate(fields.items(), start=1): + if col == "metadata": + set_clauses.append(f"{col} = ${i}::jsonb") + params.append(self._to_jsonb(val)) + else: + set_clauses.append(f"{col} = ${i}") + params.append(val) + set_clauses.append("updated_at = NOW()") + sql = ( + f"UPDATE assignments SET {', '.join(set_clauses)} " + f"WHERE id = ${len(params) + 1} RETURNING *" + ) + params.append(id) + row = await self._pool.fetchrow(sql, *params) + return self._to_read(row) if row else None + + # ---- 특수 쿼리 ---- + + async def list_by_session( + self, root_session_id: UUID, + ) -> list[AssignmentRead]: + rows = await self._pool.fetch( + "SELECT * FROM assignments WHERE root_session_id = $1 " + "ORDER BY created_at", + root_session_id, + ) + return [self._to_read(r) for r in rows] + + +__all__ = ["AssignmentRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/chat.py b/mcp/doc-store/src/doc_store_mcp/repositories/chat.py new file mode 100644 index 0000000..80ff20a --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/repositories/chat.py @@ -0,0 +1,64 @@ +"""ChatRepository — chat tier 의 chats CRUD. immutable (no update).""" + +from __future__ import annotations + +import json +from uuid import UUID + +import asyncpg +from pydantic import BaseModel + +from dev_team_shared.doc_store.schemas.chat import ChatCreate, ChatRead +from doc_store_mcp.repositories.base import PostgresRepositoryBase + + +class ChatRepository( + PostgresRepositoryBase[ChatCreate, BaseModel, ChatRead], +): + """chats 는 immutable. update 는 미지원 (호출 시 NotImplementedError).""" + + @property + def collection_name(self) -> str: + return "chats" + + def _to_read(self, row: asyncpg.Record) -> ChatRead: + d = dict(row) + for col in ("content", "metadata"): + if isinstance(d.get(col), str): + d[col] = json.loads(d[col]) + return ChatRead.model_validate(d) + + async def create(self, doc: ChatCreate) -> ChatRead: + sql = """ + INSERT INTO chats + (session_id, prev_chat_id, role, sender, content, message_id, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb) + RETURNING * + """ + row = await self._pool.fetchrow( + sql, + doc.session_id, + doc.prev_chat_id, + doc.role, + doc.sender, + self._to_jsonb(doc.content), + doc.message_id, + self._to_jsonb(doc.metadata), + ) + assert row is not None + return self._to_read(row) + + async def update(self, id: UUID, patch: BaseModel) -> ChatRead | None: # noqa: ARG002 + raise NotImplementedError("chats are immutable") + + # ---- 특수 쿼리 ---- + + async def list_by_session(self, session_id: UUID) -> list[ChatRead]: + rows = await self._pool.fetch( + "SELECT * FROM chats WHERE session_id = $1 ORDER BY created_at", + session_id, + ) + return [self._to_read(r) for r in rows] + + +__all__ = ["ChatRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/issue.py b/mcp/doc-store/src/doc_store_mcp/repositories/issue.py index c966c37..8035e21 100644 --- a/mcp/doc-store/src/doc_store_mcp/repositories/issue.py +++ b/mcp/doc-store/src/doc_store_mcp/repositories/issue.py @@ -30,14 +30,14 @@ def _to_read(self, row: asyncpg.Record) -> IssueRead: async def create(self, doc: IssueCreate) -> IssueRead: sql = """ INSERT INTO issues - (agent_task_id, type, title, body_md, status, parent_issue_id, + (assignment_id, type, title, body_md, status, parent_issue_id, labels, external_refs, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb) RETURNING * """ row = await self._pool.fetchrow( sql, - doc.agent_task_id, + doc.assignment_id, doc.type, doc.title, doc.body_md, diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/agent_task.py b/mcp/doc-store/src/doc_store_mcp/repositories/session.py similarity index 53% rename from mcp/doc-store/src/doc_store_mcp/repositories/agent_task.py rename to mcp/doc-store/src/doc_store_mcp/repositories/session.py index c0999a9..548b782 100644 --- a/mcp/doc-store/src/doc_store_mcp/repositories/agent_task.py +++ b/mcp/doc-store/src/doc_store_mcp/repositories/session.py @@ -1,4 +1,4 @@ -"""AgentTaskRepository — agent_tasks CRUD.""" +"""SessionRepository — chat tier 의 sessions CRUD.""" from __future__ import annotations @@ -7,48 +7,45 @@ import asyncpg -from doc_store_mcp.repositories.base import PostgresRepositoryBase -from dev_team_shared.doc_store.schemas.agent_task import ( - AgentTaskCreate, - AgentTaskRead, - AgentTaskUpdate, +from dev_team_shared.doc_store.schemas.session import ( + SessionCreate, + SessionRead, + SessionUpdate, ) +from doc_store_mcp.repositories.base import PostgresRepositoryBase -class AgentTaskRepository( - PostgresRepositoryBase[AgentTaskCreate, AgentTaskUpdate, AgentTaskRead], +class SessionRepository( + PostgresRepositoryBase[SessionCreate, SessionUpdate, SessionRead], ): @property def collection_name(self) -> str: - return "agent_tasks" + return "sessions" - def _to_read(self, row: asyncpg.Record) -> AgentTaskRead: + def _to_read(self, row: asyncpg.Record) -> SessionRead: d = dict(row) - # asyncpg 가 jsonb 를 str 로 반환하는 경우 (driver 버전 / type codec 미설정) if isinstance(d.get("metadata"), str): d["metadata"] = json.loads(d["metadata"]) - return AgentTaskRead.model_validate(d) + return SessionRead.model_validate(d) - async def create(self, doc: AgentTaskCreate) -> AgentTaskRead: + async def create(self, doc: SessionCreate) -> SessionRead: sql = """ - INSERT INTO agent_tasks (title, description, status, owner_agent, issue_refs, metadata) - VALUES ($1, $2, $3, $4, $5, $6::jsonb) + INSERT INTO sessions + (agent_endpoint, initiator, counterpart, metadata) + VALUES ($1, $2, $3, $4::jsonb) RETURNING * """ row = await self._pool.fetchrow( sql, - doc.title, - doc.description, - doc.status, - doc.owner_agent, - doc.issue_refs, + doc.agent_endpoint, + doc.initiator, + doc.counterpart, self._to_jsonb(doc.metadata), ) assert row is not None return self._to_read(row) - async def update(self, id: UUID, patch: AgentTaskUpdate) -> AgentTaskRead | None: - # 명시된 필드만 patch + async def update(self, id: UUID, patch: SessionUpdate) -> SessionRead | None: fields = patch.model_dump(exclude_unset=True) if not fields: return await self.get(id) @@ -61,9 +58,8 @@ async def update(self, id: UUID, patch: AgentTaskUpdate) -> AgentTaskRead | None else: set_clauses.append(f"{col} = ${i}") params.append(val) - set_clauses.append("updated_at = NOW()") sql = ( - f"UPDATE agent_tasks SET {', '.join(set_clauses)} " + f"UPDATE sessions SET {', '.join(set_clauses)} " f"WHERE id = ${len(params) + 1} RETURNING *" ) params.append(id) @@ -71,4 +67,4 @@ async def update(self, id: UUID, patch: AgentTaskUpdate) -> AgentTaskRead | None return self._to_read(row) if row else None -__all__ = ["AgentTaskRepository"] +__all__ = ["SessionRepository"] diff --git a/mcp/doc-store/src/doc_store_mcp/repositories/wiki_page.py b/mcp/doc-store/src/doc_store_mcp/repositories/wiki_page.py index 2a3e05b..43d46d2 100644 --- a/mcp/doc-store/src/doc_store_mcp/repositories/wiki_page.py +++ b/mcp/doc-store/src/doc_store_mcp/repositories/wiki_page.py @@ -36,7 +36,7 @@ def _to_read(self, row: asyncpg.Record) -> WikiPageRead: async def create(self, doc: WikiPageCreate) -> WikiPageRead: sql = """ INSERT INTO wiki_pages - (agent_task_id, page_type, slug, title, content_md, status, + (assignment_id, page_type, slug, title, content_md, status, author_agent, references_issues, references_pages, structured, external_refs, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12::jsonb) @@ -44,7 +44,7 @@ async def create(self, doc: WikiPageCreate) -> WikiPageRead: """ row = await self._pool.fetchrow( sql, - doc.agent_task_id, + doc.assignment_id, doc.page_type, doc.slug, doc.title, diff --git a/mcp/doc-store/src/doc_store_mcp/tools/__init__.py b/mcp/doc-store/src/doc_store_mcp/tools/__init__.py index eed76d7..45e93a8 100644 --- a/mcp/doc-store/src/doc_store_mcp/tools/__init__.py +++ b/mcp/doc-store/src/doc_store_mcp/tools/__init__.py @@ -2,14 +2,21 @@ 본 패키지를 import 하면 각 collection 모듈이 module-level `@mcp.tool()` 데코레이터로 자동 등록됨. 새 collection 추가 시 본 파일에 import 1줄 추가. + +#75 재설계: 10 collections. """ # noqa: F401 # imports below are for side-effect (decorator registration) from doc_store_mcp.tools import ( - agent_item, - agent_session, - agent_task, + a2a_context, + a2a_message, + a2a_task, + a2a_task_artifact, + a2a_task_status_update, + assignment, + chat, issue, + session, wiki_page, ) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/a2a_context.py b/mcp/doc-store/src/doc_store_mcp/tools/a2a_context.py new file mode 100644 index 0000000..6a4e651 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/a2a_context.py @@ -0,0 +1,70 @@ +"""a2a_contexts MCP 도구 — A2A tier 의 두 에이전트 사이 대화 namespace.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.a2a_context import ( + A2AContextCreate, + A2AContextRead, + A2AContextUpdate, +) +from dev_team_shared.doc_store.tool_names import A2AContextTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=A2AContextTools.CREATE) +async def create(ctx: Context, doc: A2AContextCreate) -> A2AContextRead: + return await _ctx(ctx).a2a_context.create(doc) + + +@mcp.tool(name=A2AContextTools.UPDATE) +async def update( + ctx: Context, id: str, patch: A2AContextUpdate, +) -> A2AContextRead | None: + return await _ctx(ctx).a2a_context.update(UUID(id), patch) + + +@mcp.tool(name=A2AContextTools.GET) +async def get(ctx: Context, id: str) -> A2AContextRead | None: + return await _ctx(ctx).a2a_context.get(UUID(id)) + + +@mcp.tool(name=A2AContextTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "started_at DESC", +) -> list[A2AContextRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).a2a_context.list(flt) + + +@mcp.tool(name=A2AContextTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).a2a_context.delete(UUID(id)) + + +@mcp.tool(name=A2AContextTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).a2a_context.count(where) + + +@mcp.tool( + name=A2AContextTools.FIND_BY_CONTEXT_ID, + description="Find the most recent a2a_context by A2A wire context_id.", +) +async def find_by_context_id( + ctx: Context, context_id: str, +) -> A2AContextRead | None: + return await _ctx(ctx).a2a_context.find_by_context_id(context_id) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/a2a_message.py b/mcp/doc-store/src/doc_store_mcp/tools/a2a_message.py new file mode 100644 index 0000000..1d5f0cb --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/a2a_message.py @@ -0,0 +1,70 @@ +"""a2a_messages MCP 도구 — A2A Message (immutable).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.a2a_message import ( + A2AMessageCreate, + A2AMessageRead, +) +from dev_team_shared.doc_store.tool_names import A2AMessageTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=A2AMessageTools.CREATE) +async def create(ctx: Context, doc: A2AMessageCreate) -> A2AMessageRead: + return await _ctx(ctx).a2a_message.create(doc) + + +@mcp.tool(name=A2AMessageTools.GET) +async def get(ctx: Context, id: str) -> A2AMessageRead | None: + return await _ctx(ctx).a2a_message.get(UUID(id)) + + +@mcp.tool(name=A2AMessageTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 200, + offset: int = 0, + order_by: str = "created_at", +) -> list[A2AMessageRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).a2a_message.list(flt) + + +@mcp.tool(name=A2AMessageTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).a2a_message.delete(UUID(id)) + + +@mcp.tool(name=A2AMessageTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).a2a_message.count(where) + + +@mcp.tool( + name=A2AMessageTools.LIST_BY_CONTEXT, + description="List messages within a given a2a_context, ordered by created_at.", +) +async def list_by_context( + ctx: Context, a2a_context_id: str, +) -> list[A2AMessageRead]: + return await _ctx(ctx).a2a_message.list_by_context(UUID(a2a_context_id)) + + +@mcp.tool( + name=A2AMessageTools.LIST_BY_TASK, + description="List Task.history messages of a given a2a_task, ordered by created_at.", +) +async def list_by_task(ctx: Context, a2a_task_id: str) -> list[A2AMessageRead]: + return await _ctx(ctx).a2a_message.list_by_task(UUID(a2a_task_id)) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/a2a_task.py b/mcp/doc-store/src/doc_store_mcp/tools/a2a_task.py new file mode 100644 index 0000000..0b02118 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/a2a_task.py @@ -0,0 +1,68 @@ +"""a2a_tasks MCP 도구 — A2A Task (stateful work).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.a2a_task import ( + A2ATaskCreate, + A2ATaskRead, + A2ATaskUpdate, +) +from dev_team_shared.doc_store.tool_names import A2ATaskTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=A2ATaskTools.CREATE) +async def create(ctx: Context, doc: A2ATaskCreate) -> A2ATaskRead: + return await _ctx(ctx).a2a_task.create(doc) + + +@mcp.tool(name=A2ATaskTools.UPDATE) +async def update( + ctx: Context, id: str, patch: A2ATaskUpdate, +) -> A2ATaskRead | None: + return await _ctx(ctx).a2a_task.update(UUID(id), patch) + + +@mcp.tool(name=A2ATaskTools.GET) +async def get(ctx: Context, id: str) -> A2ATaskRead | None: + return await _ctx(ctx).a2a_task.get(UUID(id)) + + +@mcp.tool(name=A2ATaskTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "submitted_at DESC", +) -> list[A2ATaskRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).a2a_task.list(flt) + + +@mcp.tool(name=A2ATaskTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).a2a_task.delete(UUID(id)) + + +@mcp.tool(name=A2ATaskTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).a2a_task.count(where) + + +@mcp.tool( + name=A2ATaskTools.FIND_BY_TASK_ID, + description="Find the most recent a2a_task by A2A wire task_id.", +) +async def find_by_task_id(ctx: Context, task_id: str) -> A2ATaskRead | None: + return await _ctx(ctx).a2a_task.find_by_task_id(task_id) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/a2a_task_artifact.py b/mcp/doc-store/src/doc_store_mcp/tools/a2a_task_artifact.py new file mode 100644 index 0000000..c897865 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/a2a_task_artifact.py @@ -0,0 +1,64 @@ +"""a2a_task_artifacts MCP 도구 — Task 산출물 (immutable).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.a2a_task_artifact import ( + A2ATaskArtifactCreate, + A2ATaskArtifactRead, +) +from dev_team_shared.doc_store.tool_names import A2ATaskArtifactTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=A2ATaskArtifactTools.CREATE) +async def create( + ctx: Context, doc: A2ATaskArtifactCreate, +) -> A2ATaskArtifactRead: + return await _ctx(ctx).a2a_task_artifact.create(doc) + + +@mcp.tool(name=A2ATaskArtifactTools.GET) +async def get(ctx: Context, id: str) -> A2ATaskArtifactRead | None: + return await _ctx(ctx).a2a_task_artifact.get(UUID(id)) + + +@mcp.tool(name=A2ATaskArtifactTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "created_at DESC", +) -> list[A2ATaskArtifactRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).a2a_task_artifact.list(flt) + + +@mcp.tool(name=A2ATaskArtifactTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).a2a_task_artifact.delete(UUID(id)) + + +@mcp.tool(name=A2ATaskArtifactTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).a2a_task_artifact.count(where) + + +@mcp.tool( + name=A2ATaskArtifactTools.LIST_BY_TASK, + description="List artifacts of a given a2a_task, ordered by created_at.", +) +async def list_by_task( + ctx: Context, a2a_task_id: str, +) -> list[A2ATaskArtifactRead]: + return await _ctx(ctx).a2a_task_artifact.list_by_task(UUID(a2a_task_id)) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/a2a_task_status_update.py b/mcp/doc-store/src/doc_store_mcp/tools/a2a_task_status_update.py new file mode 100644 index 0000000..bbd5697 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/a2a_task_status_update.py @@ -0,0 +1,64 @@ +"""a2a_task_status_updates MCP 도구 — Task state transition 로그 (immutable).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.a2a_task_status_update import ( + A2ATaskStatusUpdateCreate, + A2ATaskStatusUpdateRead, +) +from dev_team_shared.doc_store.tool_names import A2ATaskStatusUpdateTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=A2ATaskStatusUpdateTools.CREATE) +async def create( + ctx: Context, doc: A2ATaskStatusUpdateCreate, +) -> A2ATaskStatusUpdateRead: + return await _ctx(ctx).a2a_task_status_update.create(doc) + + +@mcp.tool(name=A2ATaskStatusUpdateTools.GET) +async def get(ctx: Context, id: str) -> A2ATaskStatusUpdateRead | None: + return await _ctx(ctx).a2a_task_status_update.get(UUID(id)) + + +@mcp.tool(name=A2ATaskStatusUpdateTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 200, + offset: int = 0, + order_by: str = "transitioned_at", +) -> list[A2ATaskStatusUpdateRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).a2a_task_status_update.list(flt) + + +@mcp.tool(name=A2ATaskStatusUpdateTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).a2a_task_status_update.delete(UUID(id)) + + +@mcp.tool(name=A2ATaskStatusUpdateTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).a2a_task_status_update.count(where) + + +@mcp.tool( + name=A2ATaskStatusUpdateTools.LIST_BY_TASK, + description="List state transitions of a given a2a_task, ordered by transitioned_at.", +) +async def list_by_task( + ctx: Context, a2a_task_id: str, +) -> list[A2ATaskStatusUpdateRead]: + return await _ctx(ctx).a2a_task_status_update.list_by_task(UUID(a2a_task_id)) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/agent_item.py b/mcp/doc-store/src/doc_store_mcp/tools/agent_item.py deleted file mode 100644 index 6c920e2..0000000 --- a/mcp/doc-store/src/doc_store_mcp/tools/agent_item.py +++ /dev/null @@ -1,57 +0,0 @@ -"""agent_items MCP 도구 — items 는 immutable. update 미노출.""" - -from __future__ import annotations - -from typing import Any -from uuid import UUID - -from dev_team_shared.doc_store.schemas.agent_item import AgentItemCreate, AgentItemRead -from dev_team_shared.doc_store.tool_names import AgentItemTools -from mcp.server.fastmcp import Context - -from doc_store_mcp.mcp_instance import AppContext, mcp -from doc_store_mcp.repositories.base import ListFilter - - -def _ctx(ctx: Context) -> AppContext: - return ctx.request_context.lifespan_context # type: ignore[return-value] - - -@mcp.tool(name=AgentItemTools.CREATE, description="Append an item (immutable).") -async def create(ctx: Context, doc: AgentItemCreate) -> AgentItemRead: - return await _ctx(ctx).agent_item.create(doc) - - -@mcp.tool(name=AgentItemTools.GET) -async def get(ctx: Context, id: str) -> AgentItemRead | None: - return await _ctx(ctx).agent_item.get(UUID(id)) - - -@mcp.tool(name=AgentItemTools.LIST) -async def list_( - ctx: Context, - where: dict[str, Any] | None = None, - limit: int = 200, - offset: int = 0, - order_by: str = "created_at", -) -> list[AgentItemRead]: - flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) - return await _ctx(ctx).agent_item.list(flt) - - -@mcp.tool(name=AgentItemTools.DELETE) -async def delete(ctx: Context, id: str) -> bool: - return await _ctx(ctx).agent_item.delete(UUID(id)) - - -@mcp.tool(name=AgentItemTools.COUNT) -async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: - return await _ctx(ctx).agent_item.count(where) - - -@mcp.tool( - name=AgentItemTools.LIST_BY_SESSION, - description="List items in a session, ordered by created_at.", -) -async def list_by_session(ctx: Context, agent_session_id: str) -> list[AgentItemRead]: - return await _ctx(ctx).agent_item.list_by_session(UUID(agent_session_id)) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/agent_session.py b/mcp/doc-store/src/doc_store_mcp/tools/agent_session.py deleted file mode 100644 index 39b0a71..0000000 --- a/mcp/doc-store/src/doc_store_mcp/tools/agent_session.py +++ /dev/null @@ -1,76 +0,0 @@ -"""agent_sessions MCP 도구.""" - -from __future__ import annotations - -from typing import Any -from uuid import UUID - -from dev_team_shared.doc_store.schemas.agent_session import ( - AgentSessionCreate, - AgentSessionRead, - AgentSessionUpdate, -) -from dev_team_shared.doc_store.tool_names import AgentSessionTools -from mcp.server.fastmcp import Context - -from doc_store_mcp.mcp_instance import AppContext, mcp -from doc_store_mcp.repositories.base import ListFilter - - -def _ctx(ctx: Context) -> AppContext: - return ctx.request_context.lifespan_context # type: ignore[return-value] - - -@mcp.tool(name=AgentSessionTools.CREATE) -async def create(ctx: Context, doc: AgentSessionCreate) -> AgentSessionRead: - return await _ctx(ctx).agent_session.create(doc) - - -@mcp.tool(name=AgentSessionTools.UPDATE) -async def update( - ctx: Context, id: str, patch: AgentSessionUpdate, -) -> AgentSessionRead | None: - return await _ctx(ctx).agent_session.update(UUID(id), patch) - - -@mcp.tool(name=AgentSessionTools.GET) -async def get(ctx: Context, id: str) -> AgentSessionRead | None: - return await _ctx(ctx).agent_session.get(UUID(id)) - - -@mcp.tool(name=AgentSessionTools.LIST) -async def list_( - ctx: Context, - where: dict[str, Any] | None = None, - limit: int = 100, - offset: int = 0, - order_by: str = "started_at DESC", -) -> list[AgentSessionRead]: - flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) - return await _ctx(ctx).agent_session.list(flt) - - -@mcp.tool(name=AgentSessionTools.DELETE) -async def delete(ctx: Context, id: str) -> bool: - return await _ctx(ctx).agent_session.delete(UUID(id)) - - -@mcp.tool(name=AgentSessionTools.COUNT) -async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: - return await _ctx(ctx).agent_session.count(where) - - -@mcp.tool( - name=AgentSessionTools.LIST_BY_TASK, - description="List sessions in a given agent_task, ordered by started_at.", -) -async def list_by_task(ctx: Context, agent_task_id: str) -> list[AgentSessionRead]: - return await _ctx(ctx).agent_session.list_by_task(UUID(agent_task_id)) - - -@mcp.tool( - name=AgentSessionTools.FIND_BY_CONTEXT, - description="Find the most recent session by A2A context_id.", -) -async def find_by_context(ctx: Context, context_id: str) -> AgentSessionRead | None: - return await _ctx(ctx).agent_session.find_by_context(context_id) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/agent_task.py b/mcp/doc-store/src/doc_store_mcp/tools/agent_task.py deleted file mode 100644 index d9bb4ba..0000000 --- a/mcp/doc-store/src/doc_store_mcp/tools/agent_task.py +++ /dev/null @@ -1,61 +0,0 @@ -"""agent_tasks MCP 도구 — Pydantic 파라미터 직접 (mcp/CLAUDE.md §1.3.1). - -도구명은 shared 의 AgentTaskTools 상수 (단일 source of truth — server / client 공유). -""" - -from __future__ import annotations - -from typing import Any -from uuid import UUID - -from dev_team_shared.doc_store.schemas.agent_task import ( - AgentTaskCreate, - AgentTaskRead, - AgentTaskUpdate, -) -from dev_team_shared.doc_store.tool_names import AgentTaskTools -from mcp.server.fastmcp import Context - -from doc_store_mcp.mcp_instance import AppContext, mcp -from doc_store_mcp.repositories.base import ListFilter - - -def _ctx(ctx: Context) -> AppContext: - return ctx.request_context.lifespan_context # type: ignore[return-value] - - -@mcp.tool(name=AgentTaskTools.CREATE, description="Create a new agent_task.") -async def create(ctx: Context, doc: AgentTaskCreate) -> AgentTaskRead: - return await _ctx(ctx).agent_task.create(doc) - - -@mcp.tool(name=AgentTaskTools.UPDATE, description="Patch update an agent_task by id.") -async def update(ctx: Context, id: str, patch: AgentTaskUpdate) -> AgentTaskRead | None: - return await _ctx(ctx).agent_task.update(UUID(id), patch) - - -@mcp.tool(name=AgentTaskTools.GET, description="Get an agent_task by id.") -async def get(ctx: Context, id: str) -> AgentTaskRead | None: - return await _ctx(ctx).agent_task.get(UUID(id)) - - -@mcp.tool(name=AgentTaskTools.LIST, description="List agent_tasks with optional filter.") -async def list_( - ctx: Context, - where: dict[str, Any] | None = None, - limit: int = 100, - offset: int = 0, - order_by: str = "created_at DESC", -) -> list[AgentTaskRead]: - flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) - return await _ctx(ctx).agent_task.list(flt) - - -@mcp.tool(name=AgentTaskTools.DELETE, description="Delete an agent_task by id.") -async def delete(ctx: Context, id: str) -> bool: - return await _ctx(ctx).agent_task.delete(UUID(id)) - - -@mcp.tool(name=AgentTaskTools.COUNT, description="Count agent_tasks with optional filter.") -async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: - return await _ctx(ctx).agent_task.count(where) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/assignment.py b/mcp/doc-store/src/doc_store_mcp/tools/assignment.py new file mode 100644 index 0000000..00bfb5e --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/assignment.py @@ -0,0 +1,70 @@ +"""assignments MCP 도구 — chat tier 의 도메인 work item.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.assignment import ( + AssignmentCreate, + AssignmentRead, + AssignmentUpdate, +) +from dev_team_shared.doc_store.tool_names import AssignmentTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=AssignmentTools.CREATE) +async def create(ctx: Context, doc: AssignmentCreate) -> AssignmentRead: + return await _ctx(ctx).assignment.create(doc) + + +@mcp.tool(name=AssignmentTools.UPDATE) +async def update( + ctx: Context, id: str, patch: AssignmentUpdate, +) -> AssignmentRead | None: + return await _ctx(ctx).assignment.update(UUID(id), patch) + + +@mcp.tool(name=AssignmentTools.GET) +async def get(ctx: Context, id: str) -> AssignmentRead | None: + return await _ctx(ctx).assignment.get(UUID(id)) + + +@mcp.tool(name=AssignmentTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "created_at DESC", +) -> list[AssignmentRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).assignment.list(flt) + + +@mcp.tool(name=AssignmentTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).assignment.delete(UUID(id)) + + +@mcp.tool(name=AssignmentTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).assignment.count(where) + + +@mcp.tool( + name=AssignmentTools.LIST_BY_SESSION, + description="List assignments derived from a given session.", +) +async def list_by_session( + ctx: Context, root_session_id: str, +) -> list[AssignmentRead]: + return await _ctx(ctx).assignment.list_by_session(UUID(root_session_id)) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/chat.py b/mcp/doc-store/src/doc_store_mcp/tools/chat.py new file mode 100644 index 0000000..bdfbdc1 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/chat.py @@ -0,0 +1,57 @@ +"""chats MCP 도구 — chat tier 의 메시지 (immutable).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.chat import ChatCreate, ChatRead +from dev_team_shared.doc_store.tool_names import ChatTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=ChatTools.CREATE) +async def create(ctx: Context, doc: ChatCreate) -> ChatRead: + return await _ctx(ctx).chat.create(doc) + + +@mcp.tool(name=ChatTools.GET) +async def get(ctx: Context, id: str) -> ChatRead | None: + return await _ctx(ctx).chat.get(UUID(id)) + + +@mcp.tool(name=ChatTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 200, + offset: int = 0, + order_by: str = "created_at", +) -> list[ChatRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).chat.list(flt) + + +@mcp.tool(name=ChatTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).chat.delete(UUID(id)) + + +@mcp.tool(name=ChatTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).chat.count(where) + + +@mcp.tool( + name=ChatTools.LIST_BY_SESSION, + description="List chats in a given session, ordered by created_at.", +) +async def list_by_session(ctx: Context, session_id: str) -> list[ChatRead]: + return await _ctx(ctx).chat.list_by_session(UUID(session_id)) diff --git a/mcp/doc-store/src/doc_store_mcp/tools/session.py b/mcp/doc-store/src/doc_store_mcp/tools/session.py new file mode 100644 index 0000000..722d905 --- /dev/null +++ b/mcp/doc-store/src/doc_store_mcp/tools/session.py @@ -0,0 +1,58 @@ +"""sessions MCP 도구 — chat tier (UG↔P/A 한 대화창).""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from dev_team_shared.doc_store.schemas.session import ( + SessionCreate, + SessionRead, + SessionUpdate, +) +from dev_team_shared.doc_store.tool_names import SessionTools +from mcp.server.fastmcp import Context + +from doc_store_mcp.mcp_instance import AppContext, mcp +from doc_store_mcp.repositories.base import ListFilter + + +def _ctx(ctx: Context) -> AppContext: + return ctx.request_context.lifespan_context # type: ignore[return-value] + + +@mcp.tool(name=SessionTools.CREATE) +async def create(ctx: Context, doc: SessionCreate) -> SessionRead: + return await _ctx(ctx).session.create(doc) + + +@mcp.tool(name=SessionTools.UPDATE) +async def update(ctx: Context, id: str, patch: SessionUpdate) -> SessionRead | None: + return await _ctx(ctx).session.update(UUID(id), patch) + + +@mcp.tool(name=SessionTools.GET) +async def get(ctx: Context, id: str) -> SessionRead | None: + return await _ctx(ctx).session.get(UUID(id)) + + +@mcp.tool(name=SessionTools.LIST) +async def list_( + ctx: Context, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "started_at DESC", +) -> list[SessionRead]: + flt = ListFilter(where=where, limit=limit, offset=offset, order_by=order_by) + return await _ctx(ctx).session.list(flt) + + +@mcp.tool(name=SessionTools.DELETE) +async def delete(ctx: Context, id: str) -> bool: + return await _ctx(ctx).session.delete(UUID(id)) + + +@mcp.tool(name=SessionTools.COUNT) +async def count(ctx: Context, where: dict[str, Any] | None = None) -> int: + return await _ctx(ctx).session.count(where) diff --git a/mcp/doc-store/tests/test_repositories.py b/mcp/doc-store/tests/test_repositories.py index 461efc6..5123f44 100644 --- a/mcp/doc-store/tests/test_repositories.py +++ b/mcp/doc-store/tests/test_repositories.py @@ -15,72 +15,183 @@ import asyncpg import pytest import pytest_asyncio - -from doc_store_mcp.repositories import ( - AgentItemRepository, - AgentSessionRepository, - AgentTaskRepository, - IssueRepository, - WikiPageRepository, -) from dev_team_shared.doc_store.schemas import ( - AgentItemCreate, - AgentSessionCreate, - AgentTaskCreate, - AgentTaskUpdate, + A2AContextCreate, + A2AMessageCreate, + A2ATaskCreate, + AssignmentCreate, + AssignmentUpdate, + ChatCreate, IssueCreate, IssueUpdate, + SessionCreate, WikiPageCreate, ) +from doc_store_mcp.repositories import ( + A2AContextRepository, + A2AMessageRepository, + A2ATaskRepository, + AssignmentRepository, + ChatRepository, + IssueRepository, + SessionRepository, + WikiPageRepository, +) + _DSN = os.environ.get( "DOC_DB_TEST_DSN", "postgres://devteam:devteam_postgres@localhost:5432/dev_team", ) +_TRUNCATE_SQL = """ + TRUNCATE + a2a_task_artifacts, + a2a_task_status_updates, + a2a_messages, + a2a_tasks, + a2a_contexts, + chats, + assignments, + sessions, + issues, + wiki_pages + RESTART IDENTITY CASCADE +""" + @pytest_asyncio.fixture async def pool() -> AsyncIterator[asyncpg.Pool]: p = await asyncpg.create_pool(dsn=_DSN, min_size=1, max_size=2) try: - # 각 테스트 시작 시 전체 정리 (간단한 인프라 — production 와 충돌 X 가정) async with p.acquire() as conn: - await conn.execute( - "TRUNCATE agent_items, agent_sessions, agent_tasks, " - "issues, wiki_pages RESTART IDENTITY CASCADE", - ) + await conn.execute(_TRUNCATE_SQL) yield p finally: await p.close() -class TestAgentTaskRepository: +# ────────────────────────────────────────────────────────────────────────── +# Chat tier +# ────────────────────────────────────────────────────────────────────────── + + +class TestSessionRepository: async def test_create_and_get(self, pool: asyncpg.Pool) -> None: - repo = AgentTaskRepository(pool) - created = await repo.create(AgentTaskCreate(title="t1")) + repo = SessionRepository(pool) + created = await repo.create(SessionCreate( + agent_endpoint="primary", counterpart="primary", + )) fetched = await repo.get(created.id) assert fetched is not None - assert fetched.title == "t1" - assert fetched.status == "open" + assert fetched.agent_endpoint == "primary" + assert fetched.initiator == "user" + + +class TestChatRepository: + async def test_chat_chain(self, pool: asyncpg.Pool) -> None: + session_repo = SessionRepository(pool) + chat_repo = ChatRepository(pool) + sess = await session_repo.create(SessionCreate( + agent_endpoint="primary", counterpart="primary", + )) + c1 = await chat_repo.create(ChatCreate( + session_id=sess.id, role="user", sender="user", + content=[{"text": "hi"}], + )) + c2 = await chat_repo.create(ChatCreate( + session_id=sess.id, prev_chat_id=c1.id, + role="agent", sender="primary", + content=[{"text": "hello"}], + )) + chats = await chat_repo.list_by_session(sess.id) + assert [c.id for c in chats] == [c1.id, c2.id] + + async def test_immutable_update_raises(self, pool: asyncpg.Pool) -> None: + from pydantic import BaseModel + + class _Empty(BaseModel): + pass + + chat_repo = ChatRepository(pool) + with pytest.raises(NotImplementedError): + await chat_repo.update(uuid.uuid4(), _Empty()) - async def test_update_patch(self, pool: asyncpg.Pool) -> None: - repo = AgentTaskRepository(pool) - created = await repo.create(AgentTaskCreate(title="t1")) - updated = await repo.update(created.id, AgentTaskUpdate(status="done")) + +class TestAssignmentRepository: + async def test_create_and_update(self, pool: asyncpg.Pool) -> None: + repo = AssignmentRepository(pool) + created = await repo.create(AssignmentCreate(title="결제 모듈")) + assert created.status == "open" + updated = await repo.update( + created.id, AssignmentUpdate(status="in_progress"), + ) assert updated is not None - assert updated.status == "done" - assert updated.title == "t1" # patch 는 명시 필드만 + assert updated.status == "in_progress" + + async def test_list_by_session(self, pool: asyncpg.Pool) -> None: + session_repo = SessionRepository(pool) + repo = AssignmentRepository(pool) + sess = await session_repo.create(SessionCreate( + agent_endpoint="primary", counterpart="primary", + )) + a = await repo.create(AssignmentCreate( + title="A", root_session_id=sess.id, + )) + await repo.create(AssignmentCreate(title="B")) # 다른 session 발 + listed = await repo.list_by_session(sess.id) + assert [x.id for x in listed] == [a.id] + - async def test_list_count_delete(self, pool: asyncpg.Pool) -> None: - repo = AgentTaskRepository(pool) - await repo.create(AgentTaskCreate(title="a")) - await repo.create(AgentTaskCreate(title="b")) - assert await repo.count() == 2 - items = await repo.list() - assert len(items) == 2 - deleted = await repo.delete(items[0].id) - assert deleted is True - assert await repo.count() == 1 +# ────────────────────────────────────────────────────────────────────────── +# A2A tier +# ────────────────────────────────────────────────────────────────────────── + + +class TestA2ATier: + async def test_context_message_task_chain(self, pool: asyncpg.Pool) -> None: + ctx_repo = A2AContextRepository(pool) + msg_repo = A2AMessageRepository(pool) + task_repo = A2ATaskRepository(pool) + + ctx = await ctx_repo.create(A2AContextCreate( + context_id="ctx-1", + initiator_agent="primary", + counterpart_agent="engineer", + )) + # standalone Message + m1 = await msg_repo.create(A2AMessageCreate( + message_id="msg-1", + a2a_context_id=ctx.id, + role="user", sender="primary", + parts=[{"kind": "text", "text": "안녕"}], + )) + # Task 생성 후 Task.history 의 message + task = await task_repo.create(A2ATaskCreate( + task_id="task-xyz", a2a_context_id=ctx.id, + )) + m2 = await msg_repo.create(A2AMessageCreate( + message_id="msg-2", + a2a_context_id=ctx.id, + a2a_task_id=task.id, + role="agent", sender="engineer", + parts=[{"kind": "text", "text": "ok"}], + )) + # list_by_context 는 둘 다, list_by_task 는 m2 만 + all_msgs = await msg_repo.list_by_context(ctx.id) + assert {m.id for m in all_msgs} == {m1.id, m2.id} + task_msgs = await msg_repo.list_by_task(task.id) + assert [m.id for m in task_msgs] == [m2.id] + # find_by_context_id / find_by_task_id + found_ctx = await ctx_repo.find_by_context_id("ctx-1") + assert found_ctx is not None and found_ctx.id == ctx.id + found_task = await task_repo.find_by_task_id("task-xyz") + assert found_task is not None and found_task.id == task.id + + +# ────────────────────────────────────────────────────────────────────────── +# 도메인 산출물 +# ────────────────────────────────────────────────────────────────────────── class TestIssueRepository: @@ -88,7 +199,6 @@ async def test_create_with_optimistic_locking(self, pool: asyncpg.Pool) -> None: repo = IssueRepository(pool) created = await repo.create(IssueCreate(type="story", title="X", body_md="...")) assert created.version == 1 - # 정상 update upd = await repo.update_with_version( created.id, IssueUpdate(status="confirmed"), @@ -99,39 +209,6 @@ async def test_create_with_optimistic_locking(self, pool: asyncpg.Pool) -> None: assert upd.status == "confirmed" -class TestAgentSessionAndItem: - async def test_chronicler_chain(self, pool: asyncpg.Pool) -> None: - task_repo = AgentTaskRepository(pool) - session_repo = AgentSessionRepository(pool) - item_repo = AgentItemRepository(pool) - - task = await task_repo.create(AgentTaskCreate(title="conv")) - session = await session_repo.create(AgentSessionCreate( - agent_task_id=task.id, - initiator="user", - counterpart="primary", - context_id="ctx-1", - )) - item1 = await item_repo.create(AgentItemCreate( - agent_session_id=session.id, - role="user", - sender="user", - content={"text": "hi"}, - )) - item2 = await item_repo.create(AgentItemCreate( - agent_session_id=session.id, - prev_item_id=item1.id, - role="agent", - sender="primary", - content={"text": "hello"}, - )) - items = await item_repo.list_by_session(session.id) - assert [i.id for i in items] == [item1.id, item2.id] - # 특수 쿼리 - found = await session_repo.find_by_context("ctx-1") - assert found is not None and found.id == session.id - - class TestWikiPageRepository: async def test_slug_unique_and_get_by_slug(self, pool: asyncpg.Pool) -> None: repo = WikiPageRepository(pool) @@ -139,11 +216,9 @@ async def test_slug_unique_and_get_by_slug(self, pool: asyncpg.Pool) -> None: await repo.create(WikiPageCreate( page_type="prd", slug=slug, title="T", content_md="body", )) - # 같은 slug 재시도 → 무결성 위반 with pytest.raises(asyncpg.UniqueViolationError): await repo.create(WikiPageCreate( page_type="prd", slug=slug, title="T2", content_md="body2", )) - # get_by_slug fetched = await repo.get_by_slug(slug) assert fetched is not None and fetched.slug == slug diff --git a/mcp/doc-store/tests/test_schemas.py b/mcp/doc-store/tests/test_schemas.py index 793c380..bb758d6 100644 --- a/mcp/doc-store/tests/test_schemas.py +++ b/mcp/doc-store/tests/test_schemas.py @@ -5,31 +5,126 @@ from uuid import uuid4 import pytest -from pydantic import ValidationError - from dev_team_shared.doc_store.schemas import ( - AgentItemCreate, - AgentSessionCreate, - AgentTaskCreate, + A2AContextCreate, + A2AMessageCreate, + A2ATaskCreate, + AssignmentCreate, + ChatCreate, IssueCreate, + SessionCreate, WikiPageCreate, ) +from pydantic import ValidationError + + +# ────────────────────────────────────────────────────────────────────────── +# Chat tier +# ────────────────────────────────────────────────────────────────────────── -class TestAgentTaskCreate: +class TestSessionCreate: def test_minimal(self) -> None: - doc = AgentTaskCreate(title="hello") + doc = SessionCreate(agent_endpoint="primary", counterpart="primary") + assert doc.initiator == "user" + assert doc.metadata == {} + + def test_extra_field_forbidden(self) -> None: + with pytest.raises(ValidationError): + SessionCreate( # type: ignore[call-arg] + agent_endpoint="primary", counterpart="primary", unknown=1, + ) + + +class TestChatCreate: + def test_role_validation(self) -> None: + for r in ("user", "agent", "system"): + ChatCreate( + session_id=uuid4(), + role=r, # type: ignore[arg-type] + sender="primary", + content=[{"text": "hi"}], + ) + + def test_invalid_role(self) -> None: + with pytest.raises(ValidationError): + ChatCreate( + session_id=uuid4(), + role="bot", # type: ignore[arg-type] + sender="x", + content={}, + ) + + +class TestAssignmentCreate: + def test_minimal(self) -> None: + doc = AssignmentCreate(title="hello") assert doc.status == "open" assert doc.metadata == {} assert doc.issue_refs == [] def test_invalid_status(self) -> None: with pytest.raises(ValidationError): - AgentTaskCreate(title="x", status="bogus") # type: ignore[arg-type] + AssignmentCreate(title="x", status="bogus") # type: ignore[arg-type] - def test_extra_field_forbidden(self) -> None: + +# ────────────────────────────────────────────────────────────────────────── +# A2A tier +# ────────────────────────────────────────────────────────────────────────── + + +class TestA2AContextCreate: + def test_minimal(self) -> None: + doc = A2AContextCreate( + context_id="ctx-1", + initiator_agent="primary", + counterpart_agent="engineer", + ) + assert doc.trace_id is None + assert doc.parent_session_id is None + assert doc.parent_assignment_id is None + + +class TestA2AMessageCreate: + def test_role_validation(self) -> None: + for r in ("user", "agent", "system"): + A2AMessageCreate( + message_id="msg-1", + a2a_context_id=uuid4(), + role=r, # type: ignore[arg-type] + sender="primary", + parts=[{"kind": "text", "text": "hi"}], + ) + + def test_optional_task_id(self) -> None: + # standalone Message — a2a_task_id 없음 + doc = A2AMessageCreate( + message_id="msg-1", + a2a_context_id=uuid4(), + role="agent", + sender="primary", + parts=[], + ) + assert doc.a2a_task_id is None + + +class TestA2ATaskCreate: + def test_default_state(self) -> None: + doc = A2ATaskCreate(task_id="task-1", a2a_context_id=uuid4()) + assert doc.state == "SUBMITTED" + + def test_invalid_state(self) -> None: with pytest.raises(ValidationError): - AgentTaskCreate(title="x", unknown_field=1) # type: ignore[call-arg] + A2ATaskCreate( + task_id="task-1", + a2a_context_id=uuid4(), + state="DRAFT", # type: ignore[arg-type] + ) + + +# ────────────────────────────────────────────────────────────────────────── +# 도메인 산출물 +# ────────────────────────────────────────────────────────────────────────── class TestIssueCreate: @@ -70,34 +165,3 @@ def test_unknown_page_type(self) -> None: title="t", content_md="b", ) - - -class TestAgentSessionCreate: - def test_minimal(self) -> None: - doc = AgentSessionCreate( - agent_task_id=uuid4(), - initiator="user", - counterpart="primary", - context_id="ctx-1", - ) - assert doc.trace_id is None - - -class TestAgentItemCreate: - def test_role_validation(self) -> None: - for r in ("user", "agent", "system"): - AgentItemCreate( - agent_session_id=uuid4(), - role=r, # type: ignore[arg-type] - sender="primary", - content={"text": "hi"}, - ) - - def test_invalid_role(self) -> None: - with pytest.raises(ValidationError): - AgentItemCreate( - agent_session_id=uuid4(), - role="bot", # type: ignore[arg-type] - sender="x", - content={}, - ) diff --git a/shared/src/dev_team_shared/doc_store/__init__.py b/shared/src/dev_team_shared/doc_store/__init__.py index 1b5ed98..39e497b 100644 --- a/shared/src/dev_team_shared/doc_store/__init__.py +++ b/shared/src/dev_team_shared/doc_store/__init__.py @@ -1,47 +1,96 @@ -"""Document DB MCP SDK — schemas + 도구명 상수 + typed client. +"""Doc Store MCP SDK — schemas + 도구명 상수 + typed client. -server (mcp/doc-store) 와 client (chronicler / 향후 librarian) 모두 본 모듈을 -공유 contract 로 import. wire-level 디테일 (도구명 / dict args / JSON parse) 은 -DocStoreClient 안에 격리되어 외부로 새지 않음. +server (mcp/doc-store) 와 client (chronicler / librarian / agents) 모두 본 +모듈을 공유 contract 로 import. wire-level 디테일 (도구명 / dict args / JSON +parse) 은 DocStoreClient 안에 격리되어 외부로 새지 않음. + +#75 재설계: chat tier (Session / Chat / Assignment) + A2A tier (5 collection) ++ 도메인 산출물 (Issue / WikiPage). 기존 AgentTask / AgentSession / AgentItem +폐기. """ from dev_team_shared.doc_store.client import DocStoreClient from dev_team_shared.doc_store.schemas import ( - AgentItemCreate, - AgentItemRead, - AgentSessionCreate, - AgentSessionRead, - AgentSessionUpdate, - AgentTaskCreate, - AgentTaskRead, - AgentTaskUpdate, + A2AContextCreate, + A2AContextRead, + A2AContextUpdate, + A2AMessageCreate, + A2AMessageRead, + A2AMessageRole, + A2ATaskArtifactCreate, + A2ATaskArtifactRead, + A2ATaskCreate, + A2ATaskRead, + A2ATaskState, + A2ATaskStatusUpdateCreate, + A2ATaskStatusUpdateRead, + A2ATaskUpdate, + AssignmentCreate, + AssignmentRead, + AssignmentStatus, + AssignmentUpdate, + ChatCreate, + ChatRead, + ChatRole, IssueCreate, IssueRead, IssueUpdate, + SessionCreate, + SessionRead, + SessionUpdate, WikiPageCreate, WikiPageRead, WikiPageUpdate, ) from dev_team_shared.doc_store.tool_names import ( - AgentItemTools, - AgentSessionTools, - AgentTaskTools, + A2AContextTools, + A2AMessageTools, + A2ATaskArtifactTools, + A2ATaskStatusUpdateTools, + A2ATaskTools, + AssignmentTools, + ChatTools, IssueTools, + SessionTools, WikiPageTools, ) __all__ = [ - "AgentItemCreate", - "AgentItemRead", - "AgentItemTools", - "AgentSessionCreate", - "AgentSessionRead", - "AgentSessionTools", - "AgentSessionUpdate", - "AgentTaskCreate", - "AgentTaskRead", - "AgentTaskTools", - "AgentTaskUpdate", + # Chat tier + "AssignmentCreate", + "AssignmentRead", + "AssignmentStatus", + "AssignmentTools", + "AssignmentUpdate", + "ChatCreate", + "ChatRead", + "ChatRole", + "ChatTools", + "SessionCreate", + "SessionRead", + "SessionTools", + "SessionUpdate", + # A2A tier + "A2AContextCreate", + "A2AContextRead", + "A2AContextTools", + "A2AContextUpdate", + "A2AMessageCreate", + "A2AMessageRead", + "A2AMessageRole", + "A2AMessageTools", + "A2ATaskArtifactCreate", + "A2ATaskArtifactRead", + "A2ATaskArtifactTools", + "A2ATaskCreate", + "A2ATaskRead", + "A2ATaskState", + "A2ATaskStatusUpdateCreate", + "A2ATaskStatusUpdateRead", + "A2ATaskStatusUpdateTools", + "A2ATaskTools", + "A2ATaskUpdate", + # 도메인 산출물 "DocStoreClient", "IssueCreate", "IssueRead", diff --git a/shared/src/dev_team_shared/doc_store/client.py b/shared/src/dev_team_shared/doc_store/client.py index 2bde106..2a4700f 100644 --- a/shared/src/dev_team_shared/doc_store/client.py +++ b/shared/src/dev_team_shared/doc_store/client.py @@ -1,13 +1,11 @@ -"""DocStoreClient — Document DB MCP 의 typed 클라이언트. +"""DocStoreClient — Doc Store MCP 의 typed 클라이언트. 호출자는 Pydantic 모델 입출력만 다룸. wire-level 디테일 (도구명 / dict 래핑 / JSON parse) 모두 본 클래스 안에 격리. -사용: - - async with StreamableMCPClient.connect(url) as mcp: - db = DocStoreClient(mcp) - item = await db.agent_item_create(AgentItemCreate(...)) # → AgentItemRead +#75 재설계: chat tier (Session / Chat / Assignment) + A2A tier (A2AContext / +A2AMessage / A2ATask / A2ATaskStatusUpdate / A2ATaskArtifact) + 도메인 산출물 +(Issue / WikiPage). 기존 AgentTask / AgentSession / AgentItem 폐기. """ from __future__ import annotations @@ -18,26 +16,43 @@ from pydantic import BaseModel from dev_team_shared.doc_store.schemas import ( - AgentItemCreate, - AgentItemRead, - AgentSessionCreate, - AgentSessionRead, - AgentSessionUpdate, - AgentTaskCreate, - AgentTaskRead, - AgentTaskUpdate, + A2AContextCreate, + A2AContextRead, + A2AContextUpdate, + A2AMessageCreate, + A2AMessageRead, + A2ATaskArtifactCreate, + A2ATaskArtifactRead, + A2ATaskCreate, + A2ATaskRead, + A2ATaskStatusUpdateCreate, + A2ATaskStatusUpdateRead, + A2ATaskUpdate, + AssignmentCreate, + AssignmentRead, + AssignmentUpdate, + ChatCreate, + ChatRead, IssueCreate, IssueRead, IssueUpdate, + SessionCreate, + SessionRead, + SessionUpdate, WikiPageCreate, WikiPageRead, WikiPageUpdate, ) from dev_team_shared.doc_store.tool_names import ( - AgentItemTools, - AgentSessionTools, - AgentTaskTools, + A2AContextTools, + A2AMessageTools, + A2ATaskArtifactTools, + A2ATaskStatusUpdateTools, + A2ATaskTools, + AssignmentTools, + ChatTools, IssueTools, + SessionTools, WikiPageTools, ) from dev_team_shared.mcp_client import StreamableMCPClient @@ -46,7 +61,7 @@ class DocStoreClient: - """Typed wrapper around `StreamableMCPClient` for Document DB MCP 도구. + """Typed wrapper around `StreamableMCPClient` for Doc Store MCP 도구. 각 collection × op 마다 1 메서드. 모든 입력은 Pydantic 모델, 모든 반환은 Pydantic 모델 / scalar 또는 None. dict / 문자열은 본 클래스 외부로 새지 않음. @@ -55,150 +70,375 @@ class DocStoreClient: def __init__(self, mcp: StreamableMCPClient) -> None: self._mcp = mcp - # ────────────────────────────────────────────────────────────────── - # agent_task - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── + # Chat tier — sessions + # ────────────────────────────────────────────────────────────────────── - async def agent_task_create(self, doc: AgentTaskCreate) -> AgentTaskRead: - return await self._call( - AgentTaskTools.CREATE, _doc(doc), AgentTaskRead, + async def session_create(self, doc: SessionCreate) -> SessionRead: + return await self._call(SessionTools.CREATE, _doc(doc), SessionRead) + + async def session_update( + self, id: UUID, patch: SessionUpdate, + ) -> SessionRead | None: + return await self._call_optional( + SessionTools.UPDATE, _id_patch(id, patch), SessionRead, + ) + + async def session_get(self, id: UUID) -> SessionRead | None: + return await self._call_optional( + SessionTools.GET, {"id": str(id)}, SessionRead, + ) + + async def session_list( + self, + *, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "started_at DESC", + ) -> list[SessionRead]: + return await self._call_list( + SessionTools.LIST, _list_args(where, limit, offset, order_by), SessionRead, + ) + + async def session_delete(self, id: UUID) -> bool: + return await self._call_scalar(SessionTools.DELETE, {"id": str(id)}) + + async def session_count(self, *, where: dict[str, Any] | None = None) -> int: + return await self._call_scalar(SessionTools.COUNT, _where_args(where)) + + # ────────────────────────────────────────────────────────────────────── + # Chat tier — chats (immutable — no update) + # ────────────────────────────────────────────────────────────────────── + + async def chat_create(self, doc: ChatCreate) -> ChatRead: + return await self._call(ChatTools.CREATE, _doc(doc), ChatRead) + + async def chat_get(self, id: UUID) -> ChatRead | None: + return await self._call_optional( + ChatTools.GET, {"id": str(id)}, ChatRead, + ) + + async def chat_list( + self, + *, + where: dict[str, Any] | None = None, + limit: int = 200, + offset: int = 0, + order_by: str = "created_at", + ) -> list[ChatRead]: + return await self._call_list( + ChatTools.LIST, _list_args(where, limit, offset, order_by), ChatRead, + ) + + async def chat_delete(self, id: UUID) -> bool: + return await self._call_scalar(ChatTools.DELETE, {"id": str(id)}) + + async def chat_count(self, *, where: dict[str, Any] | None = None) -> int: + return await self._call_scalar(ChatTools.COUNT, _where_args(where)) + + async def chat_list_by_session(self, session_id: UUID) -> list[ChatRead]: + return await self._call_list( + ChatTools.LIST_BY_SESSION, + {"session_id": str(session_id)}, + ChatRead, ) - async def agent_task_update( - self, id: UUID, patch: AgentTaskUpdate, - ) -> AgentTaskRead | None: + # ────────────────────────────────────────────────────────────────────── + # Chat tier — assignments + # ────────────────────────────────────────────────────────────────────── + + async def assignment_create(self, doc: AssignmentCreate) -> AssignmentRead: + return await self._call(AssignmentTools.CREATE, _doc(doc), AssignmentRead) + + async def assignment_update( + self, id: UUID, patch: AssignmentUpdate, + ) -> AssignmentRead | None: return await self._call_optional( - AgentTaskTools.UPDATE, _id_patch(id, patch), AgentTaskRead, + AssignmentTools.UPDATE, _id_patch(id, patch), AssignmentRead, ) - async def agent_task_get(self, id: UUID) -> AgentTaskRead | None: + async def assignment_get(self, id: UUID) -> AssignmentRead | None: return await self._call_optional( - AgentTaskTools.GET, {"id": str(id)}, AgentTaskRead, + AssignmentTools.GET, {"id": str(id)}, AssignmentRead, ) - async def agent_task_list( + async def assignment_list( self, *, where: dict[str, Any] | None = None, limit: int = 100, offset: int = 0, order_by: str = "created_at DESC", - ) -> list[AgentTaskRead]: + ) -> list[AssignmentRead]: return await self._call_list( - AgentTaskTools.LIST, + AssignmentTools.LIST, _list_args(where, limit, offset, order_by), - AgentTaskRead, + AssignmentRead, ) - async def agent_task_delete(self, id: UUID) -> bool: - return await self._call_scalar(AgentTaskTools.DELETE, {"id": str(id)}) + async def assignment_delete(self, id: UUID) -> bool: + return await self._call_scalar(AssignmentTools.DELETE, {"id": str(id)}) - async def agent_task_count(self, *, where: dict[str, Any] | None = None) -> int: - return await self._call_scalar( - AgentTaskTools.COUNT, _where_args(where), + async def assignment_count(self, *, where: dict[str, Any] | None = None) -> int: + return await self._call_scalar(AssignmentTools.COUNT, _where_args(where)) + + async def assignment_list_by_session( + self, root_session_id: UUID, + ) -> list[AssignmentRead]: + return await self._call_list( + AssignmentTools.LIST_BY_SESSION, + {"root_session_id": str(root_session_id)}, + AssignmentRead, ) - # ────────────────────────────────────────────────────────────────── - # agent_session - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── + # A2A tier — a2a_contexts + # ────────────────────────────────────────────────────────────────────── - async def agent_session_create(self, doc: AgentSessionCreate) -> AgentSessionRead: - return await self._call(AgentSessionTools.CREATE, _doc(doc), AgentSessionRead) + async def a2a_context_create(self, doc: A2AContextCreate) -> A2AContextRead: + return await self._call(A2AContextTools.CREATE, _doc(doc), A2AContextRead) - async def agent_session_update( - self, id: UUID, patch: AgentSessionUpdate, - ) -> AgentSessionRead | None: + async def a2a_context_update( + self, id: UUID, patch: A2AContextUpdate, + ) -> A2AContextRead | None: return await self._call_optional( - AgentSessionTools.UPDATE, _id_patch(id, patch), AgentSessionRead, + A2AContextTools.UPDATE, _id_patch(id, patch), A2AContextRead, ) - async def agent_session_get(self, id: UUID) -> AgentSessionRead | None: + async def a2a_context_get(self, id: UUID) -> A2AContextRead | None: return await self._call_optional( - AgentSessionTools.GET, {"id": str(id)}, AgentSessionRead, + A2AContextTools.GET, {"id": str(id)}, A2AContextRead, ) - async def agent_session_list( + async def a2a_context_list( self, *, where: dict[str, Any] | None = None, limit: int = 100, offset: int = 0, order_by: str = "started_at DESC", - ) -> list[AgentSessionRead]: + ) -> list[A2AContextRead]: return await self._call_list( - AgentSessionTools.LIST, + A2AContextTools.LIST, _list_args(where, limit, offset, order_by), - AgentSessionRead, + A2AContextRead, ) - async def agent_session_delete(self, id: UUID) -> bool: - return await self._call_scalar(AgentSessionTools.DELETE, {"id": str(id)}) - - async def agent_session_count(self, *, where: dict[str, Any] | None = None) -> int: - return await self._call_scalar(AgentSessionTools.COUNT, _where_args(where)) + async def a2a_context_delete(self, id: UUID) -> bool: + return await self._call_scalar(A2AContextTools.DELETE, {"id": str(id)}) - async def agent_session_list_by_task( - self, agent_task_id: UUID, - ) -> list[AgentSessionRead]: - return await self._call_list( - AgentSessionTools.LIST_BY_TASK, - {"agent_task_id": str(agent_task_id)}, - AgentSessionRead, - ) + async def a2a_context_count(self, *, where: dict[str, Any] | None = None) -> int: + return await self._call_scalar(A2AContextTools.COUNT, _where_args(where)) - async def agent_session_find_by_context( + async def a2a_context_find_by_context_id( self, context_id: str, - ) -> AgentSessionRead | None: + ) -> A2AContextRead | None: return await self._call_optional( - AgentSessionTools.FIND_BY_CONTEXT, + A2AContextTools.FIND_BY_CONTEXT_ID, {"context_id": context_id}, - AgentSessionRead, + A2AContextRead, ) - # ────────────────────────────────────────────────────────────────── - # agent_item (immutable — no update) - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── + # A2A tier — a2a_messages (immutable — no update) + # ────────────────────────────────────────────────────────────────────── - async def agent_item_create(self, doc: AgentItemCreate) -> AgentItemRead: - return await self._call(AgentItemTools.CREATE, _doc(doc), AgentItemRead) + async def a2a_message_create(self, doc: A2AMessageCreate) -> A2AMessageRead: + return await self._call(A2AMessageTools.CREATE, _doc(doc), A2AMessageRead) - async def agent_item_get(self, id: UUID) -> AgentItemRead | None: + async def a2a_message_get(self, id: UUID) -> A2AMessageRead | None: return await self._call_optional( - AgentItemTools.GET, {"id": str(id)}, AgentItemRead, + A2AMessageTools.GET, {"id": str(id)}, A2AMessageRead, ) - async def agent_item_list( + async def a2a_message_list( self, *, where: dict[str, Any] | None = None, limit: int = 200, offset: int = 0, order_by: str = "created_at", - ) -> list[AgentItemRead]: + ) -> list[A2AMessageRead]: + return await self._call_list( + A2AMessageTools.LIST, + _list_args(where, limit, offset, order_by), + A2AMessageRead, + ) + + async def a2a_message_delete(self, id: UUID) -> bool: + return await self._call_scalar(A2AMessageTools.DELETE, {"id": str(id)}) + + async def a2a_message_count(self, *, where: dict[str, Any] | None = None) -> int: + return await self._call_scalar(A2AMessageTools.COUNT, _where_args(where)) + + async def a2a_message_list_by_context( + self, a2a_context_id: UUID, + ) -> list[A2AMessageRead]: + return await self._call_list( + A2AMessageTools.LIST_BY_CONTEXT, + {"a2a_context_id": str(a2a_context_id)}, + A2AMessageRead, + ) + + async def a2a_message_list_by_task( + self, a2a_task_id: UUID, + ) -> list[A2AMessageRead]: + return await self._call_list( + A2AMessageTools.LIST_BY_TASK, + {"a2a_task_id": str(a2a_task_id)}, + A2AMessageRead, + ) + + # ────────────────────────────────────────────────────────────────────── + # A2A tier — a2a_tasks + # ────────────────────────────────────────────────────────────────────── + + async def a2a_task_create(self, doc: A2ATaskCreate) -> A2ATaskRead: + return await self._call(A2ATaskTools.CREATE, _doc(doc), A2ATaskRead) + + async def a2a_task_update( + self, id: UUID, patch: A2ATaskUpdate, + ) -> A2ATaskRead | None: + return await self._call_optional( + A2ATaskTools.UPDATE, _id_patch(id, patch), A2ATaskRead, + ) + + async def a2a_task_get(self, id: UUID) -> A2ATaskRead | None: + return await self._call_optional( + A2ATaskTools.GET, {"id": str(id)}, A2ATaskRead, + ) + + async def a2a_task_list( + self, + *, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "submitted_at DESC", + ) -> list[A2ATaskRead]: + return await self._call_list( + A2ATaskTools.LIST, + _list_args(where, limit, offset, order_by), + A2ATaskRead, + ) + + async def a2a_task_delete(self, id: UUID) -> bool: + return await self._call_scalar(A2ATaskTools.DELETE, {"id": str(id)}) + + async def a2a_task_count(self, *, where: dict[str, Any] | None = None) -> int: + return await self._call_scalar(A2ATaskTools.COUNT, _where_args(where)) + + async def a2a_task_find_by_task_id(self, task_id: str) -> A2ATaskRead | None: + return await self._call_optional( + A2ATaskTools.FIND_BY_TASK_ID, + {"task_id": task_id}, + A2ATaskRead, + ) + + # ────────────────────────────────────────────────────────────────────── + # A2A tier — a2a_task_status_updates (immutable — no update) + # ────────────────────────────────────────────────────────────────────── + + async def a2a_task_status_update_create( + self, doc: A2ATaskStatusUpdateCreate, + ) -> A2ATaskStatusUpdateRead: + return await self._call( + A2ATaskStatusUpdateTools.CREATE, _doc(doc), A2ATaskStatusUpdateRead, + ) + + async def a2a_task_status_update_get( + self, id: UUID, + ) -> A2ATaskStatusUpdateRead | None: + return await self._call_optional( + A2ATaskStatusUpdateTools.GET, {"id": str(id)}, A2ATaskStatusUpdateRead, + ) + + async def a2a_task_status_update_list( + self, + *, + where: dict[str, Any] | None = None, + limit: int = 200, + offset: int = 0, + order_by: str = "transitioned_at", + ) -> list[A2ATaskStatusUpdateRead]: + return await self._call_list( + A2ATaskStatusUpdateTools.LIST, + _list_args(where, limit, offset, order_by), + A2ATaskStatusUpdateRead, + ) + + async def a2a_task_status_update_delete(self, id: UUID) -> bool: + return await self._call_scalar( + A2ATaskStatusUpdateTools.DELETE, {"id": str(id)}, + ) + + async def a2a_task_status_update_count( + self, *, where: dict[str, Any] | None = None, + ) -> int: + return await self._call_scalar( + A2ATaskStatusUpdateTools.COUNT, _where_args(where), + ) + + async def a2a_task_status_update_list_by_task( + self, a2a_task_id: UUID, + ) -> list[A2ATaskStatusUpdateRead]: return await self._call_list( - AgentItemTools.LIST, + A2ATaskStatusUpdateTools.LIST_BY_TASK, + {"a2a_task_id": str(a2a_task_id)}, + A2ATaskStatusUpdateRead, + ) + + # ────────────────────────────────────────────────────────────────────── + # A2A tier — a2a_task_artifacts (immutable — no update) + # ────────────────────────────────────────────────────────────────────── + + async def a2a_task_artifact_create( + self, doc: A2ATaskArtifactCreate, + ) -> A2ATaskArtifactRead: + return await self._call( + A2ATaskArtifactTools.CREATE, _doc(doc), A2ATaskArtifactRead, + ) + + async def a2a_task_artifact_get(self, id: UUID) -> A2ATaskArtifactRead | None: + return await self._call_optional( + A2ATaskArtifactTools.GET, {"id": str(id)}, A2ATaskArtifactRead, + ) + + async def a2a_task_artifact_list( + self, + *, + where: dict[str, Any] | None = None, + limit: int = 100, + offset: int = 0, + order_by: str = "created_at DESC", + ) -> list[A2ATaskArtifactRead]: + return await self._call_list( + A2ATaskArtifactTools.LIST, _list_args(where, limit, offset, order_by), - AgentItemRead, + A2ATaskArtifactRead, ) - async def agent_item_delete(self, id: UUID) -> bool: - return await self._call_scalar(AgentItemTools.DELETE, {"id": str(id)}) + async def a2a_task_artifact_delete(self, id: UUID) -> bool: + return await self._call_scalar(A2ATaskArtifactTools.DELETE, {"id": str(id)}) - async def agent_item_count(self, *, where: dict[str, Any] | None = None) -> int: - return await self._call_scalar(AgentItemTools.COUNT, _where_args(where)) + async def a2a_task_artifact_count( + self, *, where: dict[str, Any] | None = None, + ) -> int: + return await self._call_scalar(A2ATaskArtifactTools.COUNT, _where_args(where)) - async def agent_item_list_by_session( - self, agent_session_id: UUID, - ) -> list[AgentItemRead]: + async def a2a_task_artifact_list_by_task( + self, a2a_task_id: UUID, + ) -> list[A2ATaskArtifactRead]: return await self._call_list( - AgentItemTools.LIST_BY_SESSION, - {"agent_session_id": str(agent_session_id)}, - AgentItemRead, + A2ATaskArtifactTools.LIST_BY_TASK, + {"a2a_task_id": str(a2a_task_id)}, + A2ATaskArtifactRead, ) - # ────────────────────────────────────────────────────────────────── - # issue - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── + # 도메인 산출물 — issues + # ────────────────────────────────────────────────────────────────────── async def issue_create(self, doc: IssueCreate) -> IssueRead: return await self._call(IssueTools.CREATE, _doc(doc), IssueRead) @@ -216,9 +456,7 @@ async def issue_update( return await self._call_optional(IssueTools.UPDATE, args, IssueRead) async def issue_get(self, id: UUID) -> IssueRead | None: - return await self._call_optional( - IssueTools.GET, {"id": str(id)}, IssueRead, - ) + return await self._call_optional(IssueTools.GET, {"id": str(id)}, IssueRead) async def issue_list( self, @@ -229,9 +467,7 @@ async def issue_list( order_by: str = "created_at DESC", ) -> list[IssueRead]: return await self._call_list( - IssueTools.LIST, - _list_args(where, limit, offset, order_by), - IssueRead, + IssueTools.LIST, _list_args(where, limit, offset, order_by), IssueRead, ) async def issue_delete(self, id: UUID) -> bool: @@ -240,9 +476,9 @@ async def issue_delete(self, id: UUID) -> bool: async def issue_count(self, *, where: dict[str, Any] | None = None) -> int: return await self._call_scalar(IssueTools.COUNT, _where_args(where)) - # ────────────────────────────────────────────────────────────────── - # wiki_page - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── + # 도메인 산출물 — wiki_pages + # ────────────────────────────────────────────────────────────────────── async def wiki_page_create(self, doc: WikiPageCreate) -> WikiPageRead: return await self._call(WikiPageTools.CREATE, _doc(doc), WikiPageRead) @@ -289,26 +525,19 @@ async def wiki_page_get_by_slug(self, slug: str) -> WikiPageRead | None: WikiPageTools.GET_BY_SLUG, {"slug": slug}, WikiPageRead, ) - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── # 내부 — wire 호출 + 직렬화/역직렬화 - # ────────────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────────────────── async def _call( self, name: str, args: dict[str, Any], return_type: type[T], ) -> T: - """Pydantic 모델 반환. structuredContent 가 모델 dict 자체.""" sc = await self._invoke(name, args) return return_type.model_validate(sc) async def _call_optional( self, name: str, args: dict[str, Any], return_type: type[T], ) -> T | None: - """Optional[Model] — FastMCP 가 항상 `{"result": }` 로 wrap. - - (이유: FastMCP 의 structured content 규약 — return type 이 Pydantic 모델 - 한 종(non-optional) 일 때만 unwrapped, 그 외 (Optional 포함) 는 result key - wrap. None 이 dict 가 아니라 wrap 강제.) - """ sc = await self._invoke(name, args) inner = sc.get("result") if inner is None: @@ -318,43 +547,29 @@ async def _call_optional( async def _call_list( self, name: str, args: dict[str, Any], item_type: type[T], ) -> list[T]: - """list[T] — FastMCP 가 {"result": [...]} 로 wrap.""" sc = await self._invoke(name, args) items = sc.get("result") or [] return [item_type.model_validate(it) for it in items] async def _call_scalar(self, name: str, args: dict[str, Any]) -> Any: - """bool / int / str 등 — FastMCP 가 {"result": } 로 wrap.""" sc = await self._invoke(name, args) return sc.get("result") async def _invoke(self, name: str, args: dict[str, Any]) -> dict[str, Any]: - """MCP 도구 호출 → structuredContent 반환. - - FastMCP 는 모든 응답에 structuredContent 를 채움 (MCP spec 2025-06-18): - - Pydantic 모델 → 모델 dict 그대로 - - list / scalar / None → `{"result": }` 로 wrap (MCP 의 structured - content 는 dict 만 허용하기 때문) - """ result = await self._mcp.call_tool(name, args) return result.structuredContent or {} -# ────────────────────────────────────────────────────────────────────── +# ────────────────────────────────────────────────────────────────────────── # 내부 헬퍼 — args 조립을 한 곳에서 -# ────────────────────────────────────────────────────────────────────── +# ────────────────────────────────────────────────────────────────────────── def _doc(model: BaseModel) -> dict[str, Any]: - """`{"doc": }` 래핑.""" return {"doc": model.model_dump(mode="json")} def _id_patch(id: UUID, patch: BaseModel) -> dict[str, Any]: - """update 도구의 `{"id": ..., "patch": ...}` 래핑. - - patch 는 `exclude_unset=True` 로 명시된 필드만 보냄 (Pydantic Update 시멘틱). - """ return { "id": str(id), "patch": patch.model_dump(mode="json", exclude_unset=True), diff --git a/shared/src/dev_team_shared/doc_store/schemas/__init__.py b/shared/src/dev_team_shared/doc_store/schemas/__init__.py index 6a86a4a..d9a5a7e 100644 --- a/shared/src/dev_team_shared/doc_store/schemas/__init__.py +++ b/shared/src/dev_team_shared/doc_store/schemas/__init__.py @@ -1,23 +1,58 @@ -"""Document DB MCP 의 Pydantic 스키마 — server / client 공유 contract. +"""Doc Store MCP 의 Pydantic 스키마 — server / client 공유 contract. -server (mcp/doc-store) 와 client (chronicler / 향후 librarian) 모두 본 모듈에서 -import. 이전엔 mcp/doc-store 안에 있었으나, contract 는 shared 가 owner. +server (mcp/doc-store) 와 client (chronicler / librarian / agents) 모두 본 +모듈에서 import. 이전엔 mcp/doc-store 안에 있었으나, contract 는 shared 가 owner. + +#75 재설계: chat tier (Session / Chat / Assignment) + A2A tier (A2AContext / +A2AMessage / A2ATask / A2ATaskStatusUpdate / A2ATaskArtifact) 두 영역 + 도메인 +산출물 (Issue / WikiPage). 기존 AgentTask / AgentSession / AgentItem 폐기. """ -from dev_team_shared.doc_store.schemas.agent_item import ( - AgentItemCreate, - AgentItemRead, +# Chat tier +from dev_team_shared.doc_store.schemas.assignment import ( + AssignmentCreate, + AssignmentRead, + AssignmentStatus, + AssignmentUpdate, +) +from dev_team_shared.doc_store.schemas.chat import ( + ChatCreate, + ChatRead, + ChatRole, +) +from dev_team_shared.doc_store.schemas.session import ( + SessionCreate, + SessionRead, + SessionUpdate, +) + +# A2A tier +from dev_team_shared.doc_store.schemas.a2a_context import ( + A2AContextCreate, + A2AContextRead, + A2AContextUpdate, +) +from dev_team_shared.doc_store.schemas.a2a_message import ( + A2AMessageCreate, + A2AMessageRead, + A2AMessageRole, ) -from dev_team_shared.doc_store.schemas.agent_session import ( - AgentSessionCreate, - AgentSessionRead, - AgentSessionUpdate, +from dev_team_shared.doc_store.schemas.a2a_task import ( + A2ATaskCreate, + A2ATaskRead, + A2ATaskState, + A2ATaskUpdate, ) -from dev_team_shared.doc_store.schemas.agent_task import ( - AgentTaskCreate, - AgentTaskRead, - AgentTaskUpdate, +from dev_team_shared.doc_store.schemas.a2a_task_artifact import ( + A2ATaskArtifactCreate, + A2ATaskArtifactRead, ) +from dev_team_shared.doc_store.schemas.a2a_task_status_update import ( + A2ATaskStatusUpdateCreate, + A2ATaskStatusUpdateRead, +) + +# 도메인 산출물 from dev_team_shared.doc_store.schemas.issue import ( IssueCreate, IssueRead, @@ -30,14 +65,33 @@ ) __all__ = [ - "AgentItemCreate", - "AgentItemRead", - "AgentSessionCreate", - "AgentSessionRead", - "AgentSessionUpdate", - "AgentTaskCreate", - "AgentTaskRead", - "AgentTaskUpdate", + # Chat tier + "AssignmentCreate", + "AssignmentRead", + "AssignmentStatus", + "AssignmentUpdate", + "ChatCreate", + "ChatRead", + "ChatRole", + "SessionCreate", + "SessionRead", + "SessionUpdate", + # A2A tier + "A2AContextCreate", + "A2AContextRead", + "A2AContextUpdate", + "A2AMessageCreate", + "A2AMessageRead", + "A2AMessageRole", + "A2ATaskArtifactCreate", + "A2ATaskArtifactRead", + "A2ATaskCreate", + "A2ATaskRead", + "A2ATaskState", + "A2ATaskStatusUpdateCreate", + "A2ATaskStatusUpdateRead", + "A2ATaskUpdate", + # 도메인 산출물 "IssueCreate", "IssueRead", "IssueUpdate", diff --git a/shared/src/dev_team_shared/doc_store/schemas/a2a_context.py b/shared/src/dev_team_shared/doc_store/schemas/a2a_context.py new file mode 100644 index 0000000..e84e90e --- /dev/null +++ b/shared/src/dev_team_shared/doc_store/schemas/a2a_context.py @@ -0,0 +1,54 @@ +"""a2a_contexts Pydantic 모델 — A2A 두 에이전트 사이 대화 namespace. + +A2A wire `contextId` 와 1:1. session 발 / assignment 발 / standalone (system +trigger) 셋 다 표현 가능 — `parent_session_id` / `parent_assignment_id` 모두 +NULL 이면 standalone. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class A2AContextCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + context_id: str # A2A wire contextId + initiator_agent: str + counterpart_agent: str + parent_session_id: UUID | None = None + parent_assignment_id: UUID | None = None + trace_id: str | None = None + topic: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class A2AContextUpdate(BaseModel): + """주로 ended_at / topic / metadata 갱신용.""" + + model_config = ConfigDict(extra="forbid") + + topic: str | None = None + trace_id: str | None = None + metadata: dict[str, Any] | None = None + ended_at: datetime | None = None + + +class A2AContextRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + context_id: str + initiator_agent: str + counterpart_agent: str + parent_session_id: UUID | None + parent_assignment_id: UUID | None + trace_id: str | None + topic: str | None + metadata: dict[str, Any] + started_at: datetime + ended_at: datetime | None diff --git a/shared/src/dev_team_shared/doc_store/schemas/a2a_message.py b/shared/src/dev_team_shared/doc_store/schemas/a2a_message.py new file mode 100644 index 0000000..cf0c8c8 --- /dev/null +++ b/shared/src/dev_team_shared/doc_store/schemas/a2a_message.py @@ -0,0 +1,48 @@ +"""a2a_messages Pydantic 모델 — A2A `Message` 객체 영속. + +A2A 스펙상 Message 는 두 모드: +- Standalone (taskId 없음) — trivial transaction / pre-commitment negotiation +- Task-bound (taskId 채워짐) — 이미 commit 된 Task 의 history 일원 + +`a2a_task_id` NULLABLE FK 로 표현. + +immutable — update 미노출. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +A2AMessageRole = Literal["user", "agent", "system"] + + +class A2AMessageCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + message_id: str # A2A wire messageId + a2a_context_id: UUID + a2a_task_id: UUID | None = None # Task.history 면 채움 + role: A2AMessageRole + sender: str + parts: list[dict[str, Any]] | dict[str, Any] + prev_message_id: UUID | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class A2AMessageRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + message_id: str + a2a_context_id: UUID + a2a_task_id: UUID | None + role: A2AMessageRole + sender: str + parts: list[dict[str, Any]] | dict[str, Any] + prev_message_id: UUID | None + metadata: dict[str, Any] + created_at: datetime diff --git a/shared/src/dev_team_shared/doc_store/schemas/a2a_task.py b/shared/src/dev_team_shared/doc_store/schemas/a2a_task.py new file mode 100644 index 0000000..0ceb57d --- /dev/null +++ b/shared/src/dev_team_shared/doc_store/schemas/a2a_task.py @@ -0,0 +1,56 @@ +"""a2a_tasks Pydantic 모델 — A2A `Task` 객체 영속. + +stateful long-running work tracking. 응답 형식 차원에선 Message 와 alternative +(스펙: "Tasks for Stateful Interactions"). Task 가 commit 된 후엔 관련 Message +들이 `a2a_messages.a2a_task_id` 로 backlink (Task.history). + +도메인 Assignment 와는 다른 객체 — `assignment_id` 로 어느 도메인 work item +의 진행을 위해 만들어진 A2A Task 인지 표시. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +A2ATaskState = Literal[ + "SUBMITTED", "WORKING", "COMPLETED", "INPUT_REQUIRED", "FAILED", +] + + +class A2ATaskCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + task_id: str # A2A wire taskId + a2a_context_id: UUID + state: A2ATaskState = "SUBMITTED" + assignment_id: UUID | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class A2ATaskUpdate(BaseModel): + """state 전환 + completed_at 갱신용. state transition 자체의 audit log 는 + `a2a_task_status_updates` 테이블에서 별도 누적.""" + + model_config = ConfigDict(extra="forbid") + + state: A2ATaskState | None = None + completed_at: datetime | None = None + assignment_id: UUID | None = None + metadata: dict[str, Any] | None = None + + +class A2ATaskRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + task_id: str + a2a_context_id: UUID + state: A2ATaskState + submitted_at: datetime + completed_at: datetime | None + assignment_id: UUID | None + metadata: dict[str, Any] diff --git a/shared/src/dev_team_shared/doc_store/schemas/a2a_task_artifact.py b/shared/src/dev_team_shared/doc_store/schemas/a2a_task_artifact.py new file mode 100644 index 0000000..a515435 --- /dev/null +++ b/shared/src/dev_team_shared/doc_store/schemas/a2a_task_artifact.py @@ -0,0 +1,35 @@ +"""a2a_task_artifacts Pydantic 모델 — A2A Task 의 산출물 (Artifact). + +Task 가 생성하는 출력물 (예: 설계 문서 / 코드 diff / 테스트 결과). immutable — +update 미노출. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class A2ATaskArtifactCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + a2a_task_id: UUID + artifact_id: str # A2A wire artifactId + name: str | None = None + parts: list[dict[str, Any]] | dict[str, Any] + metadata: dict[str, Any] = Field(default_factory=dict) + + +class A2ATaskArtifactRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + a2a_task_id: UUID + artifact_id: str + name: str | None + parts: list[dict[str, Any]] | dict[str, Any] + metadata: dict[str, Any] + created_at: datetime diff --git a/shared/src/dev_team_shared/doc_store/schemas/a2a_task_status_update.py b/shared/src/dev_team_shared/doc_store/schemas/a2a_task_status_update.py new file mode 100644 index 0000000..f3dddd7 --- /dev/null +++ b/shared/src/dev_team_shared/doc_store/schemas/a2a_task_status_update.py @@ -0,0 +1,35 @@ +"""a2a_task_status_updates Pydantic 모델 — A2A Task 의 state transition 로그. + +Task 의 lifecycle 동안 매 state 전환 (SUBMITTED → WORKING → ... → COMPLETED 등) +별로 1 row 누적. immutable — update 미노출. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from dev_team_shared.doc_store.schemas.a2a_task import A2ATaskState + + +class A2ATaskStatusUpdateCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + a2a_task_id: UUID + state: A2ATaskState + reason: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class A2ATaskStatusUpdateRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + a2a_task_id: UUID + state: A2ATaskState + transitioned_at: datetime + reason: str | None + metadata: dict[str, Any] diff --git a/shared/src/dev_team_shared/doc_store/schemas/agent_item.py b/shared/src/dev_team_shared/doc_store/schemas/agent_item.py deleted file mode 100644 index 4a8639f..0000000 --- a/shared/src/dev_team_shared/doc_store/schemas/agent_item.py +++ /dev/null @@ -1,39 +0,0 @@ -"""agent_items Pydantic 모델 (immutable, update 없음).""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Literal -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field - -AgentItemRole = Literal["user", "agent", "system"] - - -class AgentItemCreate(BaseModel): - """대화 메시지 1건. 한 번 쓰면 변경 X (audit 성격).""" - - model_config = ConfigDict(extra="forbid") - - agent_session_id: UUID - prev_item_id: UUID | None = None - role: AgentItemRole - sender: str - content: dict[str, Any] | list[Any] # A2A Message.parts 그대로 - message_id: str | None = None - metadata: dict[str, Any] = Field(default_factory=dict) - - -class AgentItemRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: UUID - agent_session_id: UUID - prev_item_id: UUID | None - role: AgentItemRole - sender: str - content: Any - message_id: str | None - metadata: dict[str, Any] - created_at: datetime diff --git a/shared/src/dev_team_shared/doc_store/schemas/agent_task.py b/shared/src/dev_team_shared/doc_store/schemas/assignment.py similarity index 58% rename from shared/src/dev_team_shared/doc_store/schemas/agent_task.py rename to shared/src/dev_team_shared/doc_store/schemas/assignment.py index 8bdf133..12154ae 100644 --- a/shared/src/dev_team_shared/doc_store/schemas/agent_task.py +++ b/shared/src/dev_team_shared/doc_store/schemas/assignment.py @@ -1,4 +1,8 @@ -"""agent_tasks Pydantic 모델.""" +"""assignments Pydantic 모델 — 도메인 work item. + +P / A 가 chat 중 합의해 발급. 한 Assignment 는 1 개 이상의 A2A Task 로 구성 +가능 (`a2a_tasks.assignment_id` 로 backlink). +""" from __future__ import annotations @@ -8,45 +12,42 @@ from pydantic import BaseModel, ConfigDict, Field -AgentTaskStatus = Literal["open", "in_progress", "done", "cancelled"] - +AssignmentStatus = Literal["open", "in_progress", "done", "cancelled"] -class AgentTaskCreate(BaseModel): - """create 입력 (id 미지정 — DB가 발급).""" +class AssignmentCreate(BaseModel): model_config = ConfigDict(extra="forbid") title: str description: str | None = None - status: AgentTaskStatus = "open" + status: AssignmentStatus = "open" owner_agent: str | None = None + root_session_id: UUID | None = None # 어느 chat session 에서 비롯 issue_refs: list[UUID] = Field(default_factory=list) metadata: dict[str, Any] = Field(default_factory=dict) -class AgentTaskUpdate(BaseModel): - """update 입력 — 모든 필드 선택. 명시된 필드만 patch.""" - +class AssignmentUpdate(BaseModel): model_config = ConfigDict(extra="forbid") title: str | None = None description: str | None = None - status: AgentTaskStatus | None = None + status: AssignmentStatus | None = None owner_agent: str | None = None + root_session_id: UUID | None = None issue_refs: list[UUID] | None = None metadata: dict[str, Any] | None = None -class AgentTaskRead(BaseModel): - """DB row → 외부 노출 형태.""" - +class AssignmentRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID title: str description: str | None - status: AgentTaskStatus + status: AssignmentStatus owner_agent: str | None + root_session_id: UUID | None issue_refs: list[UUID] metadata: dict[str, Any] created_at: datetime diff --git a/shared/src/dev_team_shared/doc_store/schemas/chat.py b/shared/src/dev_team_shared/doc_store/schemas/chat.py new file mode 100644 index 0000000..387cedf --- /dev/null +++ b/shared/src/dev_team_shared/doc_store/schemas/chat.py @@ -0,0 +1,41 @@ +"""chats Pydantic 모델 — session 안의 한 발화 (chat tier). + +chats 는 immutable — update 미노출. session 안의 시간순 흐름은 prev_chat_id +chain 으로 추적. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +ChatRole = Literal["user", "agent", "system"] + + +class ChatCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: UUID + prev_chat_id: UUID | None = None + role: ChatRole + sender: str # 'user' / 'primary' / ... + content: list[dict[str, Any]] | dict[str, Any] # A2A parts 형태 + message_id: str | None = None # FE 또는 server 발급 + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ChatRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + session_id: UUID + prev_chat_id: UUID | None + role: ChatRole + sender: str + content: list[dict[str, Any]] | dict[str, Any] + message_id: str | None + metadata: dict[str, Any] + created_at: datetime diff --git a/shared/src/dev_team_shared/doc_store/schemas/issue.py b/shared/src/dev_team_shared/doc_store/schemas/issue.py index 83a3bd4..563de00 100644 --- a/shared/src/dev_team_shared/doc_store/schemas/issue.py +++ b/shared/src/dev_team_shared/doc_store/schemas/issue.py @@ -15,7 +15,7 @@ class IssueCreate(BaseModel): model_config = ConfigDict(extra="forbid") - agent_task_id: UUID | None = None + assignment_id: UUID | None = None type: IssueType title: str body_md: str @@ -45,7 +45,7 @@ class IssueRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID - agent_task_id: UUID | None + assignment_id: UUID | None type: IssueType title: str body_md: str diff --git a/shared/src/dev_team_shared/doc_store/schemas/agent_session.py b/shared/src/dev_team_shared/doc_store/schemas/session.py similarity index 56% rename from shared/src/dev_team_shared/doc_store/schemas/agent_session.py rename to shared/src/dev_team_shared/doc_store/schemas/session.py index 8fa424a..d442a79 100644 --- a/shared/src/dev_team_shared/doc_store/schemas/agent_session.py +++ b/shared/src/dev_team_shared/doc_store/schemas/session.py @@ -1,4 +1,4 @@ -"""agent_sessions Pydantic 모델.""" +"""sessions Pydantic 모델 — UG↔P/A 한 대화창 (chat tier).""" from __future__ import annotations @@ -9,39 +9,31 @@ from pydantic import BaseModel, ConfigDict, Field -class AgentSessionCreate(BaseModel): +class SessionCreate(BaseModel): model_config = ConfigDict(extra="forbid") - agent_task_id: UUID - initiator: str + agent_endpoint: str # 'primary' | 'architect' + initiator: str = "user" counterpart: str - context_id: str - trace_id: str | None = None - topic: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) -class AgentSessionUpdate(BaseModel): - """주로 ended_at / topic / metadata 갱신용.""" +class SessionUpdate(BaseModel): + """주로 ended_at / metadata 갱신용.""" model_config = ConfigDict(extra="forbid") - topic: str | None = None - trace_id: str | None = None metadata: dict[str, Any] | None = None ended_at: datetime | None = None -class AgentSessionRead(BaseModel): +class SessionRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID - agent_task_id: UUID + agent_endpoint: str initiator: str counterpart: str - context_id: str - trace_id: str | None - topic: str | None metadata: dict[str, Any] started_at: datetime ended_at: datetime | None diff --git a/shared/src/dev_team_shared/doc_store/schemas/wiki_page.py b/shared/src/dev_team_shared/doc_store/schemas/wiki_page.py index d45f043..1f5bb6b 100644 --- a/shared/src/dev_team_shared/doc_store/schemas/wiki_page.py +++ b/shared/src/dev_team_shared/doc_store/schemas/wiki_page.py @@ -19,7 +19,7 @@ class WikiPageCreate(BaseModel): model_config = ConfigDict(extra="forbid") - agent_task_id: UUID | None = None + assignment_id: UUID | None = None page_type: WikiPageType slug: str title: str @@ -51,7 +51,7 @@ class WikiPageRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID - agent_task_id: UUID | None + assignment_id: UUID | None page_type: WikiPageType slug: str title: str diff --git a/shared/src/dev_team_shared/doc_store/tool_names.py b/shared/src/dev_team_shared/doc_store/tool_names.py index 2f17e7b..e24b10b 100644 --- a/shared/src/dev_team_shared/doc_store/tool_names.py +++ b/shared/src/dev_team_shared/doc_store/tool_names.py @@ -1,8 +1,11 @@ -"""Document DB MCP 도구명 상수 — server / client 공유 source of truth. +"""Doc Store MCP 도구명 상수 — server / client 공유 source of truth. 서버 (mcp/doc-store/tools/) 의 `@mcp.tool(name=...)` 등록과 클라이언트 (DocStoreClient) 의 호출 모두 본 상수 사용. 하드코딩된 문자열 사용 금지 — 변경 / 오타 방지 + IDE 자동완성. + +#75 재설계로 chat tier (Session / Chat / Assignment) + A2A tier (5 collection) ++ 도메인 산출물 (Issue / WikiPage) 로 어휘 정렬. """ from __future__ import annotations @@ -10,33 +13,103 @@ from typing import Final -class AgentTaskTools: - CREATE: Final = "agent_task.create" - UPDATE: Final = "agent_task.update" - GET: Final = "agent_task.get" - LIST: Final = "agent_task.list" - DELETE: Final = "agent_task.delete" - COUNT: Final = "agent_task.count" +# ───────────────────────────────────────────────────────────────────────────── +# Chat tier +# ───────────────────────────────────────────────────────────────────────────── + + +class SessionTools: + CREATE: Final = "session.create" + UPDATE: Final = "session.update" + GET: Final = "session.get" + LIST: Final = "session.list" + DELETE: Final = "session.delete" + COUNT: Final = "session.count" + + +class ChatTools: + """chats 는 immutable — update 미노출 (5 op + special).""" + + CREATE: Final = "chat.create" + GET: Final = "chat.get" + LIST: Final = "chat.list" + DELETE: Final = "chat.delete" + COUNT: Final = "chat.count" + LIST_BY_SESSION: Final = "chat.list_by_session" + + +class AssignmentTools: + CREATE: Final = "assignment.create" + UPDATE: Final = "assignment.update" + GET: Final = "assignment.get" + LIST: Final = "assignment.list" + DELETE: Final = "assignment.delete" + COUNT: Final = "assignment.count" + LIST_BY_SESSION: Final = "assignment.list_by_session" + + +# ───────────────────────────────────────────────────────────────────────────── +# A2A tier +# ───────────────────────────────────────────────────────────────────────────── + + +class A2AContextTools: + CREATE: Final = "a2a_context.create" + UPDATE: Final = "a2a_context.update" + GET: Final = "a2a_context.get" + LIST: Final = "a2a_context.list" + DELETE: Final = "a2a_context.delete" + COUNT: Final = "a2a_context.count" + FIND_BY_CONTEXT_ID: Final = "a2a_context.find_by_context_id" + + +class A2AMessageTools: + """a2a_messages 는 immutable — update 미노출.""" + + CREATE: Final = "a2a_message.create" + GET: Final = "a2a_message.get" + LIST: Final = "a2a_message.list" + DELETE: Final = "a2a_message.delete" + COUNT: Final = "a2a_message.count" + LIST_BY_CONTEXT: Final = "a2a_message.list_by_context" + LIST_BY_TASK: Final = "a2a_message.list_by_task" + + +class A2ATaskTools: + CREATE: Final = "a2a_task.create" + UPDATE: Final = "a2a_task.update" + GET: Final = "a2a_task.get" + LIST: Final = "a2a_task.list" + DELETE: Final = "a2a_task.delete" + COUNT: Final = "a2a_task.count" + FIND_BY_TASK_ID: Final = "a2a_task.find_by_task_id" + + +class A2ATaskStatusUpdateTools: + """a2a_task_status_updates 는 immutable — update 미노출.""" + + CREATE: Final = "a2a_task_status_update.create" + GET: Final = "a2a_task_status_update.get" + LIST: Final = "a2a_task_status_update.list" + DELETE: Final = "a2a_task_status_update.delete" + COUNT: Final = "a2a_task_status_update.count" + LIST_BY_TASK: Final = "a2a_task_status_update.list_by_task" + +class A2ATaskArtifactTools: + """a2a_task_artifacts 는 immutable — update 미노출.""" -class AgentSessionTools: - CREATE: Final = "agent_session.create" - UPDATE: Final = "agent_session.update" - GET: Final = "agent_session.get" - LIST: Final = "agent_session.list" - DELETE: Final = "agent_session.delete" - COUNT: Final = "agent_session.count" - LIST_BY_TASK: Final = "agent_session.list_by_task" - FIND_BY_CONTEXT: Final = "agent_session.find_by_context" + CREATE: Final = "a2a_task_artifact.create" + GET: Final = "a2a_task_artifact.get" + LIST: Final = "a2a_task_artifact.list" + DELETE: Final = "a2a_task_artifact.delete" + COUNT: Final = "a2a_task_artifact.count" + LIST_BY_TASK: Final = "a2a_task_artifact.list_by_task" -class AgentItemTools: - CREATE: Final = "agent_item.create" # immutable — no update - GET: Final = "agent_item.get" - LIST: Final = "agent_item.list" - DELETE: Final = "agent_item.delete" - COUNT: Final = "agent_item.count" - LIST_BY_SESSION: Final = "agent_item.list_by_session" +# ───────────────────────────────────────────────────────────────────────────── +# 도메인 산출물 +# ───────────────────────────────────────────────────────────────────────────── class IssueTools: @@ -59,9 +132,14 @@ class WikiPageTools: __all__ = [ - "AgentItemTools", - "AgentSessionTools", - "AgentTaskTools", + "A2AContextTools", + "A2AMessageTools", + "A2ATaskArtifactTools", + "A2ATaskStatusUpdateTools", + "A2ATaskTools", + "AssignmentTools", + "ChatTools", "IssueTools", + "SessionTools", "WikiPageTools", ]