From 273b061f7d733eab2b308585568698e73ac95c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=84=EC=88=98?= Date: Mon, 5 Oct 2026 16:26:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(host):=20=ED=98=B8=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EC=9D=B4=EB=B2=88=20=ED=84=B4=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EB=A5=BC=20=ED=84=B4=20=EB=85=B8=ED=8A=B8=EB=A1=9C=20=EB=84=98?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EC=84=A0=ED=83=9D=20=ED=9B=85=20turn=5Fno?= =?UTF-8?q?tes()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무엇 - HostServices.turn_notes()(OPTIONAL, 동기 함수)가 돌려준 문자열을 실행기의 폴더 안내 뒤에 SharedKeys.TURN_NOTES 로 붙인다. 문자열 하나도 받고, 빈 항목은 버린다. CLI 백엔드에도 같은 길로 간다. 왜 - 호스트가 턴마다 바뀌는 글(예: 에이전트 기억이 이번 질문에 고른 답 · 지도)을 넘길 자리가 Context 포트뿐이었다. 그 길은 사용자 메시지 본문이 되어 대화 기록 · 대화 노트 · 실행 카드 · 사실 추출 · 기억 검색어에 사용자 말로 남는다. - 턴 노트는 폴더 안내와 같이 요청 사본에만 실리고 기록에는 남지 않는다. 안전 - 훅이 없거나 예외를 던지면 예전과 같다(안내 없음, 경고 로그). - 훅이 비동기 함수면 코루틴을 닫고 무시한다(기다리지 않은 코루틴 경고를 남기지 않고 로그로 알린다). 검증: tests/test_host_turn_notes.py 7건(요청 본문 비오염 · 훅 없음 · 실패 · 비동기 훅 · 문자열 하나와 빈 항목 · 폴더 안내 순서 · CLI 백엔드). Co-Authored-By: Claude Opus 5.5 --- src/xgen_agent_runtime/host/host.py | 3 + src/xgen_agent_runtime/host/turn_executor.py | 23 +++++ tests/test_host_turn_notes.py | 98 ++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 tests/test_host_turn_notes.py diff --git a/src/xgen_agent_runtime/host/host.py b/src/xgen_agent_runtime/host/host.py index 9351554..6972aca 100644 --- a/src/xgen_agent_runtime/host/host.py +++ b/src/xgen_agent_runtime/host/host.py @@ -251,3 +251,6 @@ def cli_bridge_available(self, provider: str) -> bool: ... #: Only names the device in the per-turn folder note ("the user's Mac"). #: Absent method → the note says "device". def local_device_platform(self) -> str: ... + + #: **OPTIONAL** (sync): notes for this turn only, sent after the folder note, never in history. + def turn_notes(self) -> List[str]: ... diff --git a/src/xgen_agent_runtime/host/turn_executor.py b/src/xgen_agent_runtime/host/turn_executor.py index a0aa55e..f9854dd 100644 --- a/src/xgen_agent_runtime/host/turn_executor.py +++ b/src/xgen_agent_runtime/host/turn_executor.py @@ -10,6 +10,7 @@ from __future__ import annotations +import inspect import logging import os from dataclasses import replace @@ -199,6 +200,27 @@ def _model_vision(provider: str, model: str, override: Any = None) -> bool: exc_info=True, ) return False +def _host_turn_notes(host: Any) -> List[str]: + """호스트의 이번 턴 안내(OPTIONAL 동기 훅 ``turn_notes``). 없거나 실패하면 빈 목록.""" + probe = getattr(host, "turn_notes", None) + if not callable(probe): + return [] + try: + notes = probe() + except Exception: # noqa: BLE001 - 안내 때문에 턴을 깨지 않는다 + logger.warning("agents/geny: 호스트 턴 안내 실패 (무시)", exc_info=True) + return [] + if inspect.iscoroutine(notes): + notes.close() # 기다리지 않은 코루틴 경고를 막는다 + logger.warning( + "agents/geny: 호스트 턴 안내 훅이 비동기라 무시합니다 (동기 함수여야 합니다)" + ) + return [] + if isinstance(notes, str): + notes = [notes] + if not isinstance(notes, (list, tuple)): + return [] + return [str(n).strip() for n in notes if str(n or "").strip()] class AgentTurnExecutor: @@ -409,6 +431,7 @@ def _turn_one(name: str) -> bool: ) if _folder_note: _turn_notes.append(_folder_note) + _turn_notes.extend(_host_turn_notes(host)) if registry: logger.info( "agents/geny: %d tool(s) registered (%d deferred) from Tools/Context ports", diff --git a/tests/test_host_turn_notes.py b/tests/test_host_turn_notes.py new file mode 100644 index 0000000..ea77be9 --- /dev/null +++ b/tests/test_host_turn_notes.py @@ -0,0 +1,98 @@ +"""호스트의 선택 훅 ``turn_notes()`` 는 폴더 안내 뒤 ``SharedKeys.TURN_NOTES`` 로만 실린다.""" + +from __future__ import annotations + +from typing import Any, Dict + +import pytest + +from xgen_agent_runtime.core.shared_keys import SharedKeys +from xgen_agent_runtime.host import runner as runner_mod + +from tests.test_host_local_folders import _DeviceHost, _state +from tests.test_host_turn_executor_gates import _FakeHost, _run + +NOTE = "[기억] 같은 질문의 답이 기억에 있습니다. 이 답으로 바로 답하고 도구를 부르지 마세요." + + +@pytest.fixture +def capture(monkeypatch): + """build_pipeline 인자와 스트림에 넘어간 입력 · state 를 잡는다.""" + seen: Dict[str, Any] = {} + + def _fake_build_pipeline(**kw): + seen.update(kw) + return object() + + def _fake_stream_turn(*args, **kwargs): + seen["stream_input"] = args[1] + seen["stream_state"] = args[2] + return iter([]) + + monkeypatch.setattr(runner_mod, "build_pipeline", _fake_build_pipeline) + monkeypatch.setattr(runner_mod, "stream_turn", _fake_stream_turn) + return seen + + +class _NotesHost(_FakeHost): + def __init__(self, notes: Any, **kw: Any) -> None: + super().__init__(**kw) + self._notes = notes + + def turn_notes(self): + if isinstance(self._notes, Exception): + raise self._notes + return self._notes + + +class _NotesDeviceHost(_DeviceHost): + def turn_notes(self): + return [NOTE] + + +def test_host_notes_ride_the_turn_notes_not_the_request(capture) -> None: + _run(_NotesHost([NOTE]), capture) + assert _state(capture).shared[SharedKeys.TURN_NOTES] == [NOTE] + assert NOTE not in str(capture["stream_input"]) + + +def test_absent_hook_adds_nothing(capture) -> None: + _run(_FakeHost(), capture) + assert SharedKeys.TURN_NOTES not in _state(capture).shared + + +def test_failing_hook_is_ignored_and_the_turn_runs(capture) -> None: + _run(_NotesHost(RuntimeError("vault down")), capture) + assert SharedKeys.TURN_NOTES not in _state(capture).shared + + +def test_async_hook_is_ignored_without_an_unawaited_coroutine(capture, recwarn) -> None: + class _AsyncHost(_FakeHost): + async def turn_notes(self): + return [NOTE] + + _run(_AsyncHost(), capture) + assert SharedKeys.TURN_NOTES not in _state(capture).shared + assert not [w for w in recwarn if "never awaited" in str(w.message)] + + +def test_a_single_string_is_one_note_and_blank_items_are_dropped(capture) -> None: + _run(_NotesHost(NOTE), capture) + assert _state(capture).shared[SharedKeys.TURN_NOTES] == [NOTE] + _run(_NotesHost(["", " ", NOTE, None]), capture) + assert _state(capture).shared[SharedKeys.TURN_NOTES] == [NOTE] + + +def test_host_notes_come_after_the_folder_note(capture) -> None: + _run(_NotesDeviceHost(), capture, local_folders=[]) + notes = _state(capture).shared[SharedKeys.TURN_NOTES] + assert "No folder on the user's device" in notes[0] + assert notes[-1] == NOTE + + +def test_cli_backend_gets_the_host_notes_too(capture) -> None: + _run(_NotesDeviceHost(platform="win32"), capture, provider="claude_code", client_surface="connector", + local_folders=[{"path": "C:\\work\\docs"}]) + notes = _state(capture).shared[SharedKeys.TURN_NOTES] + assert notes[-1] == NOTE + assert any("- docs:" in n for n in notes) From 697a84cad72db099921f31593c7f03f0d4bd7783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=84=EC=88=98?= Date: Mon, 5 Oct 2026 16:26:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(tools):=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=82=AC=EA=B1=B4=EC=9D=B4=20=EA=B7=B8=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=98=20=EC=84=B1=EA=B2=A9(=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=C2=B7=20=EB=B0=94=EA=B9=A5=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=C2=B7=20=EC=B6=9C=EC=B2=98)=EC=9D=84=20?= =?UTF-8?q?=EC=8B=A3=EB=8A=94=EB=8B=A4=20(4.88.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무엇 - tool.call_start · tool.call_complete 에 도구가 스스로 말하는 능력(Tool.capabilities(input))과 출처(tool_origin)를 싣는다(사건 목록 v17). 파티션 · 순차 · 병렬 · 스트리밍 실행기와 ToolBatch 항목 모두 싣는다. 실행기에 레지스트리가 없거나 도구를 모르면 비워 보낸다(기본 능력으로 꾸미지 않는다). - 이 판이 새로 채우는 능력은 read_only 와 network_egress 다(기기 도구는 MCP 주석의 멱등 표시도 옮긴다). - BashTool.capabilities(input) · classify_shell_command: 따옴표를 아는 스캐너로 구간을 나누고, 모든 구간이 아는 읽기 프로그램이며 그 프로그램을 쓰기로 바꾸는 인자 · 파일 리다이렉트 · 명령 치환 · 프로세스 치환 · 묶음 · 다른 셸에 넘기는 코드가 없을 때만 읽기 전용(실패-닫힘). 파이썬 -c · 히어독은 쓰기 흔적을 본다. sudo 류는 읽기 전용이 아니다. 언어 · 시간대 같은 것 말고 다른 환경 변수를 앞에 붙인 명령(LD_PRELOAD= · PAGER= …)과, 다른 프로그램을 부르는 인자(less +명령 · tar -I/--checkpoint-action · sort --compress-program · git grep -O · man -P · rg --pre) · 파일을 쓰는 인자(xxd 두 번째 경로 · tree -o · file -C)와 PowerShell ForEach-Object(메서드 호출)도 읽기 전용이 아니다. 2만 자 넘는 명령은 분석하지 않는다. 결과는 명령 문자열로 캐시한다(512). 바깥 연결은 패키지 관리자의 저장소 하위 명령 · kubectl · docker pull 류도 본다. - 기기 도구(capabilities_from_annotations): MCP 어댑터와 같은 규칙(tools.mcp.adapter.annotations_to_capabilities, 이전 _annotations_to_capabilities 를 공개 이름으로)으로 옮기되 파괴 · 병렬은 기본값으로 둔다. - 포트 도구(host/tools.py): metadata · capabilities 의 read_only · network_egress 를 읽는다. read_only 가 없으면 기본 능력. - host/tool_exposure: 턴 1 표 안의 도구 발견 묶음을 TURN_ONE_DISCOVERY(= ToolSearch)로 이름 붙였다. 표의 내용과 레거시 주석은 그대로다. ToolBatch 는 안에서 실제 도구가 돌아 넣지 않는다. - 기억: memory.provider.provider_hooks() 로 저장소에 set_hooks 로 붙은 MemoryHooks 를 runner · pipeline 의 검색기가 받는다. 자동 검색 제외 분류가 있으면 키워드 · 벡터 층이 다섯 배를 받아 거른 뒤 결과 수만큼 자른다. - 버전 4.85.0, CHANGELOG. 왜 - 받는 쪽(호스트 실행 기록 · 기억 지도)이 도구 이름표를 들고 "읽기인지 쓰기인지" 를 다시 추측하던 것을 없앤다. - 리뷰(손성준 v8) 지적 반영: - 셸 분류가 따옴표 안의 | ; 를 구간으로 잘랐고, sed -i · sort -o · find -exec · git branch 새이름 · curl -o/-X POST · 히어독 뒤 > 파일 · 2> 파일 · $(…) 를 읽기 전용으로 봤다. 재현 사례를 모두 시험에 넣었다. - destructive · concurrency_safe 를 채우면 권한 자동 승격(permission/matrix) · 계획 모드 · 병렬 실행(partition)이 바뀐다. 이 판은 그 값들을 지금까지 그대로 둔다. - 기기 도구의 MCP 주석을 MCP 어댑터와 다른 규칙으로 읽었다. - 검색기가 hooks 없이 만들어져 search_exclude_categories 가 자동 검색에 먹지 않았고, 먹은 뒤에는 상위 k 가 모두 뺀 분류일 때 결과가 비었다. - ToolBatch 를 발견 그룹에 두면 그 안에서 부른 쓰기 호출도 "한 일 아님" 으로 빠진다. 영향 - hooks 를 주지 않은 저장소, 능력을 적지 않는 도구는 예전과 같다. 권한 · 계획 모드 · 병렬 실행 동작은 바뀌지 않는다. - xgen-workflow 의 기억 지도(feat/memory-gateway)는 classify_shell_command 와 사건의 capabilities 를 쓰므로 이 판(4.86.0) 릴리즈 뒤 핀을 올려 머지한다. 검증 - tests/test_tool_facts.py 9개 시험 함수, 매개변수 펼침 114건(읽기 전용 · 아닌 셸 명령 재현 사례, 파괴 · 병렬 기본값 유지, 큰 명령 시간 한도, 기기 주석, 포트 메타, 실행기 사건 partition · 순차, 턴 1 발견 묶음, provider_hooks, 제외 분류 overfetch). - 전체 시험: 새 실패 0. 이 브랜치의 실패 237건은 모두 main(e945998)에서도 실패한다(Windows 경로 구분자 등). ruff · format 통과, mypy 새 오류 0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 46 ++ docs/events.md | 8 +- pyproject.toml | 2 +- src/xgen_agent_runtime/core/pipeline.py | 5 +- src/xgen_agent_runtime/events/catalog.py | 7 +- src/xgen_agent_runtime/host/device_tools.py | 20 +- src/xgen_agent_runtime/host/runner.py | 20 +- src/xgen_agent_runtime/host/tool_exposure.py | 5 +- src/xgen_agent_runtime/host/tools.py | 21 +- src/xgen_agent_runtime/host/turn_executor.py | 2 + src/xgen_agent_runtime/memory/provider.py | 9 + src/xgen_agent_runtime/memory/retriever.py | 13 +- .../s10_tool/artifact/default/executors.py | 45 +- .../stages/s10_tool/streaming.py | 4 +- .../tools/built_in/bash_tool.py | 715 +++++++++++++++++- .../tools/built_in/tool_batch_tool.py | 4 +- src/xgen_agent_runtime/tools/mcp/adapter.py | 6 +- tests/test_tool_facts.py | 304 ++++++++ tests/unit/test_mcp_annotations_attach.py | 14 +- 19 files changed, 1213 insertions(+), 37 deletions(-) create mode 100644 tests/test_tool_facts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 29629e5..63f0813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,52 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [4.88.0] - 2026-10-11 + +### Added — 호스트가 이번 턴 안내를 턴 노트로 넘기는 선택 훅 `turn_notes()` + +호스트가 턴마다 바뀌는 글(예: 에이전트 기억이 이번 질문에 고른 답·지도)을 넘길 자리가 Context 포트뿐이었다. +그 길은 사용자 메시지 본문이 되어 대화 기록·대화 노트·실행 카드·사실 추출·기억 검색어에 사용자 말로 남는다. +`HostServices.turn_notes()`(OPTIONAL, 동기 함수)가 돌려준 문자열을 실행기의 폴더 안내 뒤에 `SharedKeys.TURN_NOTES` 로 +붙인다. 폴더 안내와 같이 요청 사본에만 실리고 기록에는 남지 않는다. 훅이 없거나 실패하거나 비동기면 예전과 같다(안내 없음). + +### Added — 도구 호출 사건이 그 호출의 성격을 싣는다 (`capabilities` · `origin`, 사건 목록 v17) + +`tool.call_start` · `tool.call_complete` 에 도구가 스스로 말하는 능력(`Tool.capabilities(input)`)과 출처(`tool_origin`)를 +싣는다. 받는 쪽(호스트 실행 기록 · 기억)이 도구 이름표를 들고 "읽기인지 쓰기인지" 를 다시 추측하지 않는다. 파티션 · +순차 · 병렬 · 스트리밍 실행기와 ToolBatch 항목 모두 싣는다. 실행기에 레지스트리가 없거나 도구를 모르면 비워 보낸다(CLI +백엔드는 싣지 않는다). + +이 판이 새로 채우는 능력은 **읽기 전용(read_only)과 바깥 연결(network_egress)**이다(기기 도구는 MCP 주석의 멱등 표시도 +옮긴다). 파괴(destructive) · 병렬(concurrency_safe)은 권한 확인 · 계획 모드 · 병렬 실행을 바꾸므로 지금까지의 값을 그대로 둔다. + +- `BashTool.capabilities(input)` · `classify_shell_command`: 명령마다 답한다. 실패-닫힘 - 따옴표 밖의 모든 구간이 아는 + 읽기 프로그램이고, 그 프로그램을 쓰기로 바꾸는 플래그(`sed -i` · `sort -o` · `find -delete/-exec` · `git branch 새이름` · + `curl -o/-X POST/--data` …)가 없고, 파일로 쓰는 리다이렉트(`>` `>>` `2>` `&>`) · 명령 치환 · 프로세스 치환 · 묶음(`{ }`) + · 다른 셸이나 인터프리터에 넘기는 코드(`bash -c` · `node -e` …)가 없을 때만 읽기 전용이다. 파이썬 인라인 코드 + (`-c` · 히어독)는 쓰기 흔적을 본다. 권한을 올리는 명령(sudo 등)과, 언어 · 시간대 같은 것 말고 다른 환경 변수를 앞에 붙인 + 명령(`LD_PRELOAD=…` · `PAGER=…`)은 읽기 전용이 아니다. 2만 자가 넘는 명령은 분석하지 + 않는다(읽기 전용 아님). 결과는 명령 문자열로 캐시한다. 파괴 · 병렬 여부는 기본값. +- 기기 도구(`host/device_tools.capabilities_from_annotations`): MCP 도구 주석을 MCP 어댑터와 같은 규칙 + (`tools.mcp.adapter.annotations_to_capabilities`, 이전 이름 `_annotations_to_capabilities` 를 공개로)으로 옮기되 + 파괴 · 병렬은 기본값으로 둔다. 주석이 없으면 기본 능력. +- 포트 도구(`host/tools.py`): LangChain 도구의 `metadata` · 호출 가능 사전의 `capabilities` 에 적힌 `read_only` · + `network_egress` 를 읽는다(호스트 노드가 적는다). `read_only` 가 없으면 기본 능력. +- `host/tool_exposure.TURN_ONE_DISCOVERY`(= ToolSearch): 턴 1 표 안의 도구 발견 묶음에 이름을 붙였다. 표의 내용은 같다. + 호스트가 "도구를 찾은 호출은 한 일이 아니다" 를 가릴 때 읽는다. ToolBatch 는 안에서 실제 도구가 돌아 넣지 않는다. + +### Fixed — 호스트가 `set_hooks` 로 붙인 기억 hooks 가 자동 검색에 먹지 않던 것 + +runner · pipeline 이 검색기(`MemoryAwareRetriever`)를 hooks 없이 만들어, 저장소에 `set_hooks` 로 붙인 `MemoryHooks` 가 +자동 검색에 쓰이지 않았다. 이제 검색기가 저장소의 hooks(`memory.provider.provider_hooks`, MemoryHooks 일 때만)를 받는다. +`set_hooks` 를 부르는 호스트는 hooks 의 모든 검색 설정(제외 분류 · 결과 수 · 주입 예산 · slim_mode · 층별 예산 …)이 +적용된다. 생성자로 hooks 를 받은 저장소도 같다. hooks 는 파이프라인을 지을 때 한 번 읽으므로 그 전에 붙여야 한다. hooks 를 +주지 않은 저장소는 기본 `MemoryHooks()` 라 검색 결과가 그대로다. + +자동 검색에서 뺄 분류(`search_exclude_categories`)가 있으면 키워드 · 벡터 층이 다섯 배를 받아 거른 뒤 결과 수만큼 자른다. +뺀 분류가 저장소의 대부분일 때 상위 k 가 전부 그 분류라 거르고 나면 비던 것을 막는다. 기억 도구(`memory_search`)로 +직접 찾는 길은 영향이 없다. + ## [4.87.1] - 2026-10-11 ### Fixed: 도구 사이 진행 메모가 영어로 나오던 것 diff --git a/docs/events.md b/docs/events.md index 260646d..f1ff693 100644 --- a/docs/events.md +++ b/docs/events.md @@ -3,8 +3,8 @@ -> Generated from `xgen_agent_runtime.events.catalog` on 2026-09-30. -> Catalogue version: **16** · events: **140** +> Generated from `xgen_agent_runtime.events.catalog` on 2026-10-11. +> Catalogue version: **17** · events: **140** Every event name the engine emits, value == wire string. The enum is a *names registry*, not a rename — consumers matching raw strings @@ -716,6 +716,8 @@ Enum member: `EventTypes.TOOL_CALL_START` | `tool_use_id` | str | | `name` | str | | `input` | dict | +| `capabilities` | dict — {read_only, destructive, network_egress, idempotent} from Tool.capabilities(input); absent when the executor's registry does not know the tool | +| `origin` | str — tool_origin(): builtin / adapted / device / memory / mcp / '' | ### `tool.call_complete` @@ -726,6 +728,8 @@ Enum member: `EventTypes.TOOL_CALL_COMPLETE` | `tool_use_id` | str | | `name` | str | | `is_error` | bool | +| `capabilities` | dict — same as tool.call_start | +| `origin` | str — same as tool.call_start | | `duration_ms` | int | ### `tool.repeat_failure` diff --git a/pyproject.toml b/pyproject.toml index e976aa5..892b1d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "xgen-agent-runtime" -version = "4.87.1" +version = "4.88.0" description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API" readme = "README.md" license = "Apache-2.0" diff --git a/src/xgen_agent_runtime/core/pipeline.py b/src/xgen_agent_runtime/core/pipeline.py index 6a319b5..96dd432 100644 --- a/src/xgen_agent_runtime/core/pipeline.py +++ b/src/xgen_agent_runtime/core/pipeline.py @@ -1487,6 +1487,7 @@ def from_manifest( if manifest_memory: try: from xgen_agent_runtime.memory.factory import provider_from_manifest_memory + from xgen_agent_runtime.memory.provider import provider_hooks as _provider_hooks from xgen_agent_runtime.memory.retriever import MemoryAwareRetriever from xgen_agent_runtime.memory.strategy import ProviderDrivenStrategy @@ -1512,7 +1513,9 @@ def from_manifest( else: pipeline._memory_provider = memory_provider pipeline._apply_runtime( - memory_retriever=MemoryAwareRetriever(memory_provider), + memory_retriever=MemoryAwareRetriever( + memory_provider, hooks=_provider_hooks(memory_provider) + ), memory_strategy=ProviderDrivenStrategy(memory_provider), ) diff --git a/src/xgen_agent_runtime/events/catalog.py b/src/xgen_agent_runtime/events/catalog.py index ba64654..5cf9d56 100644 --- a/src/xgen_agent_runtime/events/catalog.py +++ b/src/xgen_agent_runtime/events/catalog.py @@ -64,7 +64,8 @@ #: (4.71.0 — Stage 12/13 and sub-agent orchestration removed). The members #: stay (removal is a major-version change) but nothing emits them any more; #: see :data:`RETIRED_EVENT_TYPES`. -EVENT_CATALOG_VERSION = 16 +#: v17: ``tool.call_start`` / ``tool.call_complete`` gain ``capabilities`` / ``origin``. +EVENT_CATALOG_VERSION = 17 class EventTypes(str, Enum): @@ -673,11 +674,15 @@ class EventTypes(str, Enum): "tool_use_id": "str", "name": "str", "input": "dict", + "capabilities": "dict — {read_only, destructive, network_egress, idempotent} from Tool.capabilities(input); absent when the executor's registry does not know the tool", + "origin": "str — tool_origin(): builtin / adapted / device / memory / mcp / ''", }, EventTypes.TOOL_CALL_COMPLETE: { "tool_use_id": "str", "name": "str", "is_error": "bool", + "capabilities": "dict — same as tool.call_start", + "origin": "str — same as tool.call_start", "duration_ms": "int", }, EventTypes.TOOL_REVIEW_FLAG: { diff --git a/src/xgen_agent_runtime/host/device_tools.py b/src/xgen_agent_runtime/host/device_tools.py index 18fe19f..cb5183c 100644 --- a/src/xgen_agent_runtime/host/device_tools.py +++ b/src/xgen_agent_runtime/host/device_tools.py @@ -21,11 +21,12 @@ from __future__ import annotations import logging +from dataclasses import replace from typing import Any, Awaitable, Callable, Dict, List, Optional from xgen_agent_runtime.host.local_folders import device_tool_name, model_tool_name from xgen_agent_runtime.tools import Tool, ToolResult, build_tool -from xgen_agent_runtime.tools.base import with_origin +from xgen_agent_runtime.tools.base import ToolCapabilities, with_origin logger = logging.getLogger("xgen_agent_runtime.host.device_tools") @@ -150,6 +151,20 @@ def to_tool_result(name: str, payload: Any) -> ToolResult: return ToolResult(content=str(result)) +def capabilities_from_annotations(annotations: Any) -> Optional[ToolCapabilities]: + """MCP 어댑터와 같은 규칙으로 옮기되 파괴 · 병렬은 기본값으로 둔다. 주석이 없으면 None.""" + if not isinstance(annotations, dict) or not annotations: + return None + from xgen_agent_runtime.tools.mcp.adapter import annotations_to_capabilities + + base = ToolCapabilities() + return replace( + annotations_to_capabilities(annotations), + concurrency_safe=base.concurrency_safe, + destructive=base.destructive, + ) + + def build_device_tool( *, server: str, @@ -157,6 +172,7 @@ def build_device_tool( description: str, input_schema: Any, call: DeviceCall, + annotations: Any = None, ) -> Tool: """기기 카탈로그 한 줄 → 런타임 도구. 모델 이름은 :func:`model_tool_name` 이 정한다.""" name = model_tool_name(server, tool) @@ -182,6 +198,7 @@ async def _execute(tool_input: Dict[str, Any], ctx: Any) -> ToolResult: description=str(description or f"Device tool {tool} on {server}"), input_schema=schema, execute=_execute, + capabilities=capabilities_from_annotations(annotations), ), "device", ) @@ -213,5 +230,6 @@ async def _execute(tool_input: Dict[str, Any], ctx: Any) -> ToolResult: "WRONG_MACHINE", "build_device_guide", "build_device_tool", + "capabilities_from_annotations", "to_tool_result", ] diff --git a/src/xgen_agent_runtime/host/runner.py b/src/xgen_agent_runtime/host/runner.py index a98bc3b..dfcab3b 100644 --- a/src/xgen_agent_runtime/host/runner.py +++ b/src/xgen_agent_runtime/host/runner.py @@ -764,6 +764,7 @@ def build_pipeline( # (Geny 의 MemoryContextBlock 경로와 동일 — 2.50 분리형 블록 사용) try: from xgen_agent_runtime.memory.retriever import MemoryAwareRetriever + from xgen_agent_runtime.memory.provider import provider_hooks from xgen_agent_runtime.host.conversation_archive import ConversationArchivingStrategy from xgen_agent_runtime.stages.s03_system.artifact.default.builders import ( @@ -777,7 +778,9 @@ def build_pipeline( pipeline._memory_provider = memory_provider pipeline.attach_runtime( - memory_retriever=MemoryAwareRetriever(memory_provider), + memory_retriever=MemoryAwareRetriever( + memory_provider, hooks=provider_hooks(memory_provider) + ), memory_strategy=ConversationArchivingStrategy(memory_provider), system_builder=ComposablePromptBuilder( blocks=[ @@ -916,11 +919,24 @@ def _attach_tool_use_id(event: Dict[str, Any], tool_use_id: Any) -> None: event["run_id"] = value +def _attach_tool_facts(event: Dict[str, Any], data: Any) -> None: + """파이프라인 사건의 능력 · 출처를 호스트 사건에 싣는다.""" + if not isinstance(data, dict): + return + caps = data.get("capabilities") + if isinstance(caps, dict): + event["capabilities"] = dict(caps) + origin = data.get("origin") + if isinstance(origin, str) and origin: + event["origin"] = origin + + def _tool_call_event( name: str, tool_input: Any, *, tool_use_id: Any = None, + facts: Any = None, ) -> Dict[str, Any]: event: Dict[str, Any] = { "type": "tool_call", @@ -931,6 +947,7 @@ def _tool_call_event( "timestamp": datetime.now().isoformat(), } _attach_tool_use_id(event, tool_use_id) + _attach_tool_facts(event, facts) indicator = _indicator(name) if indicator: event["indicator"] = indicator @@ -1600,6 +1617,7 @@ def stream_turn( event.data.get("name", ""), event.data.get("input"), tool_use_id=event.data.get("tool_use_id"), + facts=event.data, ), } elif tool_events and event.type == "tool.call_complete": diff --git a/src/xgen_agent_runtime/host/tool_exposure.py b/src/xgen_agent_runtime/host/tool_exposure.py index 3d8ec36..97273a8 100644 --- a/src/xgen_agent_runtime/host/tool_exposure.py +++ b/src/xgen_agent_runtime/host/tool_exposure.py @@ -72,6 +72,9 @@ def sends_every_schema(value: object) -> bool: # 메모리·작업·위임·커넥터)에 흩어져 있고, 각자가 자기 판단으로 ``core=True`` 를 # 쓰면 표면은 아무도 의도하지 않은 모양이 된다. +#: 도구를 찾기만 하는 호출(실제 작업이 아니다). +TURN_ONE_DISCOVERY = frozenset({"ToolSearch"}) + #: 첫 턴에 스키마까지 보이는 도구. #: #: 각 줄은 **입구 하나**다. 패밀리 전체를 올리는 줄은 없다 — 기본 명령만 예외인데, @@ -86,7 +89,7 @@ def sends_every_schema(value: object) -> bool: "Glob", "Grep", # 2. 도구 발견 — 아래 계층 전부로 가는 문. - "ToolSearch", + *TURN_ONE_DISCOVERY, # 2-b. 목록 작업 — 같은 도구를 여러 입력으로 한 왕복에. 첫 턴에 보여야 # 항목마다 따로 부르는 습관(왕복 N회)이 처음부터 생기지 않는다. "ToolBatch", diff --git a/src/xgen_agent_runtime/host/tools.py b/src/xgen_agent_runtime/host/tools.py index 97c74e1..c3d3b8b 100644 --- a/src/xgen_agent_runtime/host/tools.py +++ b/src/xgen_agent_runtime/host/tools.py @@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional from xgen_agent_runtime.tools import Tool, ToolRegistry, ToolResult, build_tool -from xgen_agent_runtime.tools.base import with_origin +from xgen_agent_runtime.tools.base import ToolCapabilities, with_origin logger = logging.getLogger("editor.geny_bridge.tools") @@ -190,6 +190,16 @@ def _denied_result(name: str, denied: str, result_sink: Optional[Dict[str, str]] return ToolResult(content=text, is_error=True) +def _capabilities_of(meta: Any) -> Optional[ToolCapabilities]: + """노드가 적은 ``read_only`` · ``network_egress`` 만 옮긴다(파괴 · 병렬은 기본값). ``read_only`` 가 없으면 None.""" + if not isinstance(meta, dict) or "read_only" not in meta: + return None + return ToolCapabilities( + read_only=meta.get("read_only") is True, + network_egress=meta.get("network_egress") is True, + ) + + def _wrap_langchain(lc_tool: Any, result_sink: Optional[Dict[str, str]], taken: Any = ()) -> Tool: original = str(getattr(lc_tool, "name", "") or type(lc_tool).__name__) name = _sanitize_name(original, taken) @@ -230,6 +240,7 @@ async def _execute(tool_input: Dict[str, Any], ctx: Any) -> ToolResult: description=description, input_schema=_json_schema_of(lc_tool), execute=_execute, + capabilities=_capabilities_of(getattr(lc_tool, "metadata", None)), ), "adapted", ) @@ -268,7 +279,13 @@ async def _execute(tool_input: Dict[str, Any], ctx: Any) -> ToolResult: return ToolResult(content=text) return with_origin( - build_tool(name=name, description=description, input_schema=input_schema, execute=_execute), + build_tool( + name=name, + description=description, + input_schema=input_schema, + execute=_execute, + capabilities=_capabilities_of(spec.get("capabilities")), + ), "adapted", ) diff --git a/src/xgen_agent_runtime/host/turn_executor.py b/src/xgen_agent_runtime/host/turn_executor.py index f9854dd..d3e9618 100644 --- a/src/xgen_agent_runtime/host/turn_executor.py +++ b/src/xgen_agent_runtime/host/turn_executor.py @@ -200,6 +200,8 @@ def _model_vision(provider: str, model: str, override: Any = None) -> bool: exc_info=True, ) return False + + def _host_turn_notes(host: Any) -> List[str]: """호스트의 이번 턴 안내(OPTIONAL 동기 훅 ``turn_notes``). 없거나 실패하면 빈 목록.""" probe = getattr(host, "turn_notes", None) diff --git a/src/xgen_agent_runtime/memory/provider.py b/src/xgen_agent_runtime/memory/provider.py index 5762779..c01cf49 100644 --- a/src/xgen_agent_runtime/memory/provider.py +++ b/src/xgen_agent_runtime/memory/provider.py @@ -1181,6 +1181,15 @@ async def promote(self, ref: NoteRef, to: Scope) -> NoteRef: ... def set_hooks(self, hooks: "MemoryHooks") -> None: ... +def provider_hooks(provider: Any) -> Optional["MemoryHooks"]: + """``set_hooks`` 로 붙은 :class:`MemoryHooks`, 없으면 None. 파이프라인을 지을 때 한 번만 읽힌다.""" + for attr in ("hooks", "_hooks"): + h = getattr(provider, attr, None) + if isinstance(h, MemoryHooks): + return h + return None + + # ───────────────────────────────────────────────────────────────────── # 11. MemoryHooks — pluggable policy attached to a provider / stage # ───────────────────────────────────────────────────────────────────── diff --git a/src/xgen_agent_runtime/memory/retriever.py b/src/xgen_agent_runtime/memory/retriever.py index 9e3f27a..b42bc3f 100644 --- a/src/xgen_agent_runtime/memory/retriever.py +++ b/src/xgen_agent_runtime/memory/retriever.py @@ -77,6 +77,10 @@ def _scan_rows(hooks: MemoryHooks) -> int: return max(scan, max(0, int(hooks.recent_turns)) * 4) +#: 뺄 분류(``search_exclude_categories``)가 있으면 거른 뒤에도 k 개가 남도록 이 배수만큼 더 받는다. +_EXCLUDED_OVERFETCH = 5 + + def _layer_cap(hooks: MemoryHooks, layer: str) -> int: """Resolve the per-layer character cap from the hooks bag.""" ratio = hooks.layer_budget_ratio.get(layer, 0.0) @@ -376,6 +380,7 @@ async def _fetch_summary() -> Any: # memory_search tool calls are unaffected): screen-observation style # buffers can dominate a vault and drown real recall. _excluded = set(getattr(hooks, "search_exclude_categories", ()) or ()) + _fetch_k = hooks.max_results * _EXCLUDED_OVERFETCH if _excluded else hooks.max_results def _drop_excluded(hits): if not _excluded or not hits: @@ -386,7 +391,7 @@ def _drop_excluded(hits): if cat in _excluded: continue kept.append(h) - return kept + return kept[: hooks.max_results] if hooks.slim_mode or hooks.always_render_vault_map: _add( @@ -405,14 +410,12 @@ async def _fetch_vector() -> Any: vec = self._provider.vector() if vec is None: return None - return _drop_excluded(await vec.search(query, top_k=hooks.max_results)) + return _drop_excluded(await vec.search(query, top_k=_fetch_k)) _add("vector", _fetch_vector) async def _fetch_kw_notes() -> Any: - return _drop_excluded( - await self._provider.notes().search(query, limit=hooks.max_results) - ) + return _drop_excluded(await self._provider.notes().search(query, limit=_fetch_k)) _add("kw_notes", _fetch_kw_notes) _add("kw_ltm", lambda: self._provider.ltm().search(query, limit=hooks.max_results)) diff --git a/src/xgen_agent_runtime/stages/s10_tool/artifact/default/executors.py b/src/xgen_agent_runtime/stages/s10_tool/artifact/default/executors.py index 8eb490f..b865db6 100644 --- a/src/xgen_agent_runtime/stages/s10_tool/artifact/default/executors.py +++ b/src/xgen_agent_runtime/stages/s10_tool/artifact/default/executors.py @@ -70,7 +70,35 @@ def _router_registry(router: ToolRouter) -> Optional[ToolRegistry]: return reg if isinstance(reg, ToolRegistry) else None -def _emit_call_start(on_event: Optional[ToolEventCallback], tc: Dict[str, Any]) -> None: +def _tool_facts(registry: Optional[ToolRegistry], tc: Dict[str, Any]) -> Dict[str, Any]: + """이 호출의 능력 · 출처. 도구를 모르면 기본 능력으로 꾸미지 않고 빈 사전.""" + if registry is None: + return {} + tool = registry.get(tc.get("tool_name", "")) + if tool is None: + return {} + from xgen_agent_runtime.tools.base import tool_origin + + try: + caps = tool.capabilities(tc.get("tool_input", {})) + except Exception: # noqa: BLE001 - 능력을 몰라도 호출은 막지 않는다 + return {"origin": tool_origin(tool)} + return { + "capabilities": { + "read_only": bool(caps.read_only), + "destructive": bool(caps.destructive), + "network_egress": bool(caps.network_egress), + "idempotent": bool(caps.idempotent), + }, + "origin": tool_origin(tool), + } + + +def _emit_call_start( + on_event: Optional[ToolEventCallback], + tc: Dict[str, Any], + registry: Optional[ToolRegistry] = None, +) -> None: if on_event is None: return on_event( @@ -79,6 +107,7 @@ def _emit_call_start(on_event: Optional[ToolEventCallback], tc: Dict[str, Any]) "tool_use_id": tc.get("tool_use_id", ""), "name": tc.get("tool_name", ""), "input": tc.get("tool_input", {}), + **_tool_facts(registry, tc), }, ) @@ -140,6 +169,7 @@ def _emit_call_complete( tc: Dict[str, Any], result_dict: Dict[str, Any], duration_ms: int, + registry: Optional[ToolRegistry] = None, ) -> None: if on_event is None: return @@ -149,6 +179,7 @@ def _emit_call_complete( "name": tc.get("tool_name", ""), "is_error": is_error, "duration_ms": duration_ms, + **_tool_facts(registry, tc), } if is_error: # **사유를 함께 싣는다.** 성공 결과는 크고 모델이 이미 받지만, 실패 @@ -195,7 +226,7 @@ async def execute_all( registry = _router_registry(router) results = [] for tc in tool_calls: - _emit_call_start(on_event, tc) + _emit_call_start(on_event, tc, registry) t0 = time.monotonic() result = await router.route( tc["tool_name"], @@ -213,7 +244,7 @@ async def execute_all( ) _apply_state_mutations_via_ctx(result, tc, context) result_dict = result.to_api_format(tc["tool_use_id"]) - _emit_call_complete(on_event, tc, result_dict, duration_ms) + _emit_call_complete(on_event, tc, result_dict, duration_ms, registry) results.append(result_dict) return results @@ -285,7 +316,7 @@ async def execute_all( async def _execute_one(tc: Dict[str, Any]) -> Dict[str, Any]: async with semaphore: - _emit_call_start(on_event, tc) + _emit_call_start(on_event, tc, registry) t0 = time.monotonic() result = await router.route( tc["tool_name"], @@ -303,7 +334,7 @@ async def _execute_one(tc: Dict[str, Any]) -> Dict[str, Any]: ) _apply_state_mutations_via_ctx(result, tc, context) result_dict = result.to_api_format(tc["tool_use_id"]) - _emit_call_complete(on_event, tc, result_dict, duration_ms) + _emit_call_complete(on_event, tc, result_dict, duration_ms, registry) return result_dict tasks = [_execute_one(tc) for tc in tool_calls] @@ -413,7 +444,7 @@ async def execute_all( semaphore = asyncio.Semaphore(self._max_concurrency) async def _run_one(tc: Dict[str, Any]) -> Dict[str, Any]: - _emit_call_start(on_event, tc) + _emit_call_start(on_event, tc, self._registry) t0 = time.monotonic() result = await router.route( tc["tool_name"], @@ -431,7 +462,7 @@ async def _run_one(tc: Dict[str, Any]) -> Dict[str, Any]: ) _apply_state_mutations_via_ctx(result, tc, context) result_dict = result.to_api_format(tc["tool_use_id"]) - _emit_call_complete(on_event, tc, result_dict, duration_ms) + _emit_call_complete(on_event, tc, result_dict, duration_ms, self._registry) return result_dict async def _run_bounded(tc: Dict[str, Any]) -> Dict[str, Any]: diff --git a/src/xgen_agent_runtime/stages/s10_tool/streaming.py b/src/xgen_agent_runtime/stages/s10_tool/streaming.py index 7f41dfe..c3d46b4 100644 --- a/src/xgen_agent_runtime/stages/s10_tool/streaming.py +++ b/src/xgen_agent_runtime/stages/s10_tool/streaming.py @@ -366,7 +366,7 @@ async def _dispatch(self, tuid: str, context: ToolContext) -> Dict[str, Any]: """Shared execution body — routes, times, emits events, stores result.""" call = self._calls[tuid] on_event = getattr(self, "_on_event", None) - _emit_call_start(on_event, call) + _emit_call_start(on_event, call, self._registry) t0 = time.monotonic() assert self._router is not None, "router must be bound before dispatch" result = await self._router.route( @@ -396,6 +396,6 @@ async def _dispatch(self, tuid: str, context: ToolContext) -> Dict[str, Any]: exc_info=True, ) result_dict = result.to_api_format(tuid) - _emit_call_complete(on_event, call, result_dict, duration_ms) + _emit_call_complete(on_event, call, result_dict, duration_ms, self._registry) self._results[tuid] = result_dict return result_dict diff --git a/src/xgen_agent_runtime/tools/built_in/bash_tool.py b/src/xgen_agent_runtime/tools/built_in/bash_tool.py index 0a2e5a0..efbd800 100644 --- a/src/xgen_agent_runtime/tools/built_in/bash_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/bash_tool.py @@ -3,16 +3,19 @@ from __future__ import annotations import asyncio +import functools import logging import os import re +import shlex import signal import sys -from typing import Any, Dict, FrozenSet, Mapping, Optional +from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple from xgen_agent_runtime.tools.base import ( HOST_IS_EXECUTION_TARGET, Tool, + ToolCapabilities, ToolContext, ToolResult, ) @@ -279,6 +282,713 @@ def _detached_process_reason(command: str) -> Optional[str]: _MAX_TIMEOUT_MS = 600_000 # 10 minutes _MAX_OUTPUT = 100_000 # characters +# ── 셸 명령 분류 (BashTool.capabilities) ─────────────────────────────────────── +# 실패-닫힘: 모든 구간이 아는 읽기 프로그램이고 쓰기 흔적이 없을 때만 읽기 전용이다. + +#: 이보다 긴 명령은 분석하지 않고 읽기 전용이 아니라고 본다. +_CLASSIFY_MAX_CHARS = 20_000 + +#: 인자와 상관없이 읽기만 하는 프로그램(소문자). +_READ_ONLY_PROGRAMS = frozenset( + { + "ls", + "dir", + "cat", + "head", + "tail", + "grep", + "egrep", + "fgrep", + "ag", + "ack", + "wc", + "echo", + "printf", + "pwd", + "which", + "whereis", + "type", + "printenv", + "cal", + "whoami", + "id", + "uname", + "df", + "du", + "stat", + "cut", + "tr", + "diff", + "cmp", + "comm", + "md5sum", + "sha1sum", + "sha256sum", + "sha512sum", + "basename", + "dirname", + "realpath", + "readlink", + "test", + "[", + "true", + "false", + "jq", + "hexdump", + "od", + "strings", + "column", + "nl", + "paste", + "fold", + "seq", + "ps", + "pgrep", + "lsof", + "free", + "uptime", + "nproc", + "lscpu", + "lsblk", + "ping", + "nslookup", + "dig", + "host", + "traceroute", + "netstat", + "ss", + "help", + "tac", + "rev", + "expr", + "bc", + "sleep", + "wait", + "locale", + "getconf", + "lsb_release", + "last", + "w", + "who", + "zcat", + "bzcat", + "xzcat", + "base64", + "cd", + "pushd", + "popd", + "export", + "set", + "unset", + "get-childitem", + "get-content", + "get-item", + "get-location", + "select-string", + "get-date", + "get-process", + "write-output", + "write-host", + "test-path", + "resolve-path", + "get-command", + "measure-object", + "select-object", + "where-object", + "format-table", + "format-list", + "out-string", + "convertto-json", + "convertfrom-json", + "sort-object", + "get-help", + "get-member", + "findstr", + "where", + "tasklist", + "systeminfo", + "ver", + "ipconfig", + } +) +_EGRESS_PROGRAMS = frozenset( + { + "curl", + "wget", + "ssh", + "scp", + "sftp", + "rsync", + "ping", + "nslookup", + "dig", + "host", + "traceroute", + "nc", + "netcat", + "telnet", + "invoke-webrequest", + "invoke-restmethod", + "iwr", + "irm", + } +) +_PKG_MANAGERS = frozenset( + { + "pip", + "pip3", + "npm", + "pnpm", + "yarn", + "apt", + "apt-get", + "apk", + "conda", + "uv", + "brew", + "yum", + "dnf", + "cargo", + "pipx", + "gem", + "go", + "poetry", + "choco", + "winget", + } +) +_PKG_READ_ONLY = frozenset( + { + "show", + "list", + "freeze", + "search", + "info", + "ls", + "view", + "outdated", + "why", + "version", + "--version", + "-v", + "-V", + "help", + "--help", + } +) +#: 저장소(레지스트리)에 닿지 않는 하위 명령. 그 밖은 바깥 연결로 본다. +_PKG_LOCAL = frozenset( + {"", "list", "ls", "freeze", "why", "env", "version", "--version", "-v", "-V", "help", "--help"} +) +_DOCKER_EGRESS = frozenset({"pull", "push", "login", "search", "build", "run"}) +_GIT_READ_ONLY = frozenset( + { + "log", + "status", + "diff", + "show", + "rev-parse", + "ls-files", + "ls-tree", + "blame", + "describe", + "shortlog", + "grep", + "cat-file", + "rev-list", + "name-rev", + "var", + "version", + "--version", + "check-ignore", + "merge-base", + "ls-remote", + "count-objects", + "whatchanged", + } +) +_GIT_EGRESS = frozenset({"clone", "fetch", "pull", "push", "ls-remote", "submodule"}) +_DOCKER_READ_ONLY = frozenset( + { + "ps", + "images", + "logs", + "inspect", + "version", + "info", + "stats", + "top", + "port", + "diff", + "history", + "search", + "events", + "--version", + } +) +_KUBECTL_READ_ONLY = frozenset( + { + "get", + "describe", + "logs", + "top", + "version", + "cluster-info", + "api-resources", + "explain", + "diff", + } +) +_PYTHONS = frozenset({"python", "python3", "python2", "py"}) +_INTERPRETERS = ( + frozenset( + { + "node", + "ruby", + "perl", + "php", + "bash", + "sh", + "zsh", + "dash", + "ksh", + "fish", + "pwsh", + "powershell", + "cmd", + "rscript", + "deno", + "bun", + "lua", + "tclsh", + "osascript", + } + ) + | _PYTHONS +) +_WRAPPERS = frozenset({"time", "nohup", "command", "exec", "busybox"}) +#: 권한을 올리는 명령. 뒤의 명령과 상관없이 읽기 전용이 아니다. +_ELEVATE = frozenset({"sudo", "doas", "su", "runas", "gsudo"}) +#: 파이썬 인라인 코드의 쓰기 · 실행 흔적. +_PY_WRITE_RE = re.compile( + r"\bto_(?:excel|csv|json|parquet|pickle|sql|feather|hdf)\(|\.save(?:fig)?\(|\bwrite_(?:text|bytes)\(|\.write\(" + r"|\bopen\([^)]*[\"'][^\"']*[wax+]" + r"|\bos\.(?:remove|unlink|rename|rmdir|removedirs|makedirs|mkdir|replace|chmod|chown|symlink|link|system|popen|exec\w*|spawn\w*|kill)\(" + r"|\bshutil\.|\bsubprocess\b|\bpathlib\b[^\n]*\.(?:write_text|write_bytes|unlink|rename|mkdir|touch|rmdir)\(" + r"|\.(?:write_text|write_bytes|unlink|touch|rmdir|mkdir|rename|replace)\(|\beval\(|\bexec\(|\b__import__\(", + re.IGNORECASE, +) +#: 바깥으로 나가는 흔적(인라인 코드 · 명령 텍스트). +_NET_RE = re.compile( + r"\brequests\.(?:get|post|put|delete|patch|head|request|session)\b|\burlopen\(|\burllib\.request\b|\bhttpx\." + r"|\baiohttp\.|\bfetch\(|\bsocket\.", + re.IGNORECASE, +) +_HEREDOC_RE = re.compile( + r"<<-?\s*(['\"]?)(\w+)\1([^\n]*)\n(.*?)\n[ \t]*\2[ \t]*(?:\n|$)", re.DOTALL +) +_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +#: 앞에 붙여도 실행할 코드를 바꾸지 않는 환경 변수(그 밖의 변수는 LD_PRELOAD · PAGER 처럼 다른 코드를 부를 수 있다). +_SAFE_ENV = frozenset( + {"LANG", "LANGUAGE", "TZ", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "COLUMNS", "LINES", "TERM"} +) +_SAFE_SINKS = frozenset({"/dev/null", "/dev/stdout", "/dev/stderr", "nul", "$null"}) + + +def _scan_shell(text: str) -> Tuple[List[str], bool]: + """따옴표 밖의 연산자로 구간을 나누고, 치환 · 묶음 · 파일 리다이렉트 · 닫히지 않은 따옴표가 있으면 unsafe.""" + segs: List[str] = [] + buf: List[str] = [] + unsafe = False + quote = "" + i, n = 0, len(text) + + def flush() -> None: + s = "".join(buf).strip() + if s: + segs.append(s) + buf.clear() + + while i < n: + c = text[i] + if quote == "'": + buf.append(c) + quote = "" if c == "'" else quote + i += 1 + continue + if c == "\\" and i + 1 < n: + buf.append(text[i : i + 2]) + i += 2 + continue + if quote == '"': + if c == '"': + quote = "" + elif c == "`" or text.startswith("$(", i): + unsafe = True + buf.append(c) + i += 1 + continue + if c in "'\"": + quote = c + buf.append(c) + i += 1 + continue + if c == "#" and (i == 0 or text[i - 1] in " \t\n;|&"): + while i < n and text[i] != "\n": + i += 1 + continue + if c == "`" or c in "{}" or text.startswith("$(", i) or text.startswith("<(", i): + unsafe = True + if c == ">" or text.startswith("&>", i): + j = i + (2 if c == "&" else 1) + if j < n and text[j] in ">|": + j += 1 + if c == ">" and j < n and text[j] == "&": # >&2 · >&- 는 스트림 복제 + buf.append(text[i : j + 1]) + i = j + 1 + continue + k = j + while k < n and text[k] in " \t": + k += 1 + m = re.match(r"[^\s;|&<>()]+", text[k:]) + target = (m.group(0) if m else "").strip("'\"").lower() + if target not in _SAFE_SINKS: + unsafe = True + buf.append(text[i:k]) + i = k + continue + two = text[i : i + 2] + if two in ("||", "&&", "|&"): + flush() + i += 2 + continue + if c in "|;&\n": + flush() + i += 1 + continue + buf.append(c) + i += 1 + if quote: + unsafe = True + flush() + return segs, unsafe + + +def _safe_assignment(word: str) -> bool: + name = word.split("=", 1)[0] + return name in _SAFE_ENV or name.startswith("LC_") + + +def _strip_wrappers(tokens: List[str]) -> Tuple[str, List[str]]: + """앞의 환경 변수 · 감싸는 명령을 벗긴 프로그램(소문자 basename)과 인자.""" + t = list(tokens) + while t: + head = t[0] + if _ASSIGN_RE.match(head): + if not _safe_assignment(head): + return "=", [] + t.pop(0) + continue + name = head.replace("\\", "/").rsplit("/", 1)[-1].lower() + if name.endswith(".exe"): + name = name[:-4] + if name in _WRAPPERS: + t.pop(0) + continue + if name == "env": + rest = t[1:] + while rest and (rest[0].startswith("-") or _ASSIGN_RE.match(rest[0])): + if _ASSIGN_RE.match(rest[0]) and not _safe_assignment(rest[0]): + return "=", [] + if rest[0] in ("-u", "--unset", "-C", "--chdir", "-S", "--split-string"): + rest = rest[2:] + else: + rest = rest[1:] + if not rest: + return "env", [] + t = rest + continue + if name == "nice": + t = t[1:] + if t and t[0] == "-n": + t = t[2:] + elif t and re.fullmatch(r"-\d+", t[0]): + t = t[1:] + continue + if name in ("timeout", "stdbuf"): + t = t[1:] + while t and t[0].startswith("-"): + t = t[2:] if t[0] in ("-s", "--signal", "-k", "--kill-after") else t[1:] + if name == "timeout" and t: + t = t[1:] # 시간 + continue + return name, t[1:] + return "", [] + + +def _positional(args: List[str]) -> List[str]: + return [a for a in args if not a.startswith("-")] + + +def _has_flag(args: List[str], *names: str, short: str = "") -> bool: + """``--name`` / ``--name=…`` / 짧은 플래그 묶음(``-sSLo``) 안의 글자.""" + for a in args: + if a == "--": + break + for nm in names: + if a == nm or a.startswith(nm + "="): + return True + if ( + short + and a.startswith("-") + and not a.startswith("--") + and any(ch in a[1:] for ch in short) + ): + return True + return False + + +def _args_read_only(prog: str, args: List[str], stdin_code: str) -> bool: + pos = _positional(args) + if prog in _READ_ONLY_PROGRAMS: + return True + if prog == "env": + return not args + if prog in ("less", "more"): + return not any(a.startswith("+") for a in args) + if prog == "rg": + return not _has_flag(args, "--pre") + if prog == "file": + return not _has_flag(args, "--compile", short="C") + if prog == "xxd": + return len(pos) <= 1 + if prog == "tree": + return not _has_flag(args, short="o") + if prog == "man": + return not _has_flag(args, "--pager", "--html", short="PH") + if prog == "date": + return not _has_flag(args, "--set", short="s") + if prog == "hostname": + return not pos + if prog in ("sort",): + return not _has_flag(args, "--output", "--compress-program", short="o") + if prog == "uniq": + return len(pos) <= 1 + if prog == "iconv": + return not _has_flag(args, "--output", short="o") + if prog == "yq": + return not _has_flag(args, "--inplace", short="i") + if prog in ("awk", "gawk", "mawk", "nawk"): + if _has_flag(args, "--file", "--inplace", short="f") or any(a == "-i" for a in args): + return False + program = pos[0] if pos else "" + return not re.search(r"system\s*\(|>|\||getline|fflush|close\s*\(", program) + if prog == "sed": + if _has_flag(args, "--in-place", "--file", short="if"): + return False + script = " ".join( + pos[:1] + [a for i, a in enumerate(args) if i and args[i - 1] in ("-e", "--expression")] + ) + return not re.search(r"(?:^|[;}\s/])[wWe](?:\s|$)|/[gpIiMm0-9]*w\s", script) + if prog == "find": + return not any( + a + in ( + "-delete", + "-exec", + "-execdir", + "-ok", + "-okdir", + "-fprint", + "-fprint0", + "-fprintf", + "-fls", + ) + for a in args + ) + if prog == "tar": + if _has_flag( + args, + "--delete", + "--extract", + "--get", + "--create", + "--append", + "--update", + "--concatenate", + "--to-command", + "--checkpoint-action", + "--use-compress-program", + "--info-script", + "--new-volume-script", + "--rsh-command", + short="IF", + ): + return False + mode = args[0].lstrip("-") if args and not args[0].startswith("--") else "" + return ("t" in mode or _has_flag(args, "--list", short="t")) and not any( + ch in mode for ch in "xcruAIF" + ) + if prog in ("unzip",): + return _has_flag(args, short="lZ") or "-l" in args + if prog == "zipinfo": + return True + if prog == "git": + sub = pos[0] if pos else "" + rest = args[args.index(sub) + 1 :] if sub in args else [] + if _has_flag(args, "--output", "-o"): + return False + if sub == "branch": + return not _positional(rest) or _has_flag(rest, "--list", short="l") + if sub == "tag": + return not _positional(rest) or _has_flag(rest, "--list", short="l") + if sub == "remote": + return not rest or rest[0] in ("-v", "--verbose", "show", "get-url") + if sub == "config": + return _has_flag(rest, "--get", "--get-all", "--get-regexp", "--list", short="l") + if sub == "stash": + return bool(rest) and rest[0] in ("list", "show") + if sub == "reflog": + return not rest or rest[0] == "show" + if _has_flag(rest, "--ext-diff", "--textconv") or ( + sub == "grep" and _has_flag(rest, "--open-files-in-pager", short="O") + ): + return False + return sub in _GIT_READ_ONLY + if prog == "docker": + sub = pos[0] if pos else "" + if sub == "compose": + return any(a in ("ps", "logs", "config", "ls", "images") for a in pos[1:2]) + return sub in _DOCKER_READ_ONLY + if prog == "kubectl": + sub = pos[0] if pos else "" + if sub == "config": + return any( + a in ("view", "get-contexts", "current-context", "get-clusters") for a in pos[1:2] + ) + return sub in _KUBECTL_READ_ONLY + if prog in _PKG_MANAGERS: + sub = pos[0] if pos else (args[0] if args else "") + if prog == "go" and sub == "env": + return not _has_flag(args, "-w", "-u") + return sub in _PKG_READ_ONLY + if prog == "curl": + if _has_flag( + args, + "--output", + "--remote-name", + "--remote-name-all", + "--upload-file", + "--data", + "--data-raw", + "--data-binary", + "--data-urlencode", + "--json", + "--form", + "--config", + "--cookie-jar", + "--dump-header", + "--trace", + "--trace-ascii", + "--stderr", + short="oOTdFKcD", + ): + return False + for i, a in enumerate(args): + if a in ("-X", "--request") and i + 1 < len(args): + if args[i + 1].upper() not in ("GET", "HEAD"): + return False + elif a.startswith("-X") and len(a) > 2 and a[2:].upper() not in ("GET", "HEAD"): + return False + return True + if prog == "wget": + for i, a in enumerate(args): + if a in ("-O", "--output-document") and i + 1 < len(args) and args[i + 1] == "-": + return True + if a in ("-O-", "-qO-", "--output-document=-", "--spider"): + return True + return False + if prog in ("invoke-webrequest", "invoke-restmethod", "iwr", "irm"): + low = [a.lower() for a in args] + if "-outfile" in low or "-infile" in low or "-body" in low: + return False + for i, a in enumerate(low): + if a == "-method" and i + 1 < len(low) and low[i + 1] not in ("get", "head"): + return False + return True + if prog in _PYTHONS: + if _has_flag(args, "--version", "--help", short="Vh") and not pos and "-c" not in args: + return True + if "-c" in args: + i = args.index("-c") + code = args[i + 1] if i + 1 < len(args) else "" + return bool(code) and not _PY_WRITE_RE.search(code) + if stdin_code and (not pos or pos == ["-"]): + return not _PY_WRITE_RE.search(stdin_code) + return False # 스크립트 · 모듈 실행은 안을 모른다 + if prog in _INTERPRETERS: + return _has_flag(args, "--version", "--help") and len(args) == 1 + return False + + +def _egress(prog: str, args: List[str], code: str) -> bool: + pos = _positional(args) + if prog in _EGRESS_PROGRAMS: + return True + if prog == "git": + return bool(pos) and pos[0] in _GIT_EGRESS + if prog in _PKG_MANAGERS: + return (pos[0] if pos else (args[0] if args else "")) not in _PKG_LOCAL + if prog == "docker": + return bool(pos) and pos[0] in _DOCKER_EGRESS + if prog == "kubectl": + return not (pos and pos[0] == "config") + if prog in _PYTHONS or prog in _INTERPRETERS: + return bool(_NET_RE.search(" ".join(args) + "\n" + code)) + return False + + +@functools.lru_cache(maxsize=512) +def _classify(command: str) -> Tuple[bool, bool]: + if len(command) > _CLASSIFY_MAX_CHARS: + return False, bool(_NET_RE.search(command[:_CLASSIFY_MAX_CHARS])) + bodies: List[str] = [] + + def take(m: "re.Match[str]") -> str: + bodies.append(m.group(4)) + # 같은 줄의 나머지(리다이렉트 · 파이프)는 남긴다 + return "<<" + m.group(2) + m.group(3) + "\n" + + text = _HEREDOC_RE.sub(take, command) + stdin_code = "\n".join(bodies) + segments, unsafe = _scan_shell(text) + read_only = bool(segments) and not unsafe + egress = False + for seg in segments: + try: + tokens = shlex.split(seg, posix=True) + except ValueError: + read_only = False + continue + tokens = [t for t in tokens if not re.fullmatch(r"<<-?\w+|<< ToolCapabilities: + """읽기 전용 · 바깥 연결만 판정한다. 셸은 작업 디렉터리 · 환경을 공유하므로 병렬은 늘 끈다.""" + read_only, egress = _classify(str(command or "")) + return ToolCapabilities( + concurrency_safe=False, + read_only=read_only, + network_egress=egress, + ) + async def _finish_result( *, @@ -431,6 +1141,9 @@ def input_schema(self) -> Dict[str, Any]: "required": ["command"], } + def capabilities(self, input: Dict[str, Any]) -> ToolCapabilities: + return classify_shell_command(str((input or {}).get("command") or "")) + async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResult: command = input.get("command", "").strip() if not command: diff --git a/src/xgen_agent_runtime/tools/built_in/tool_batch_tool.py b/src/xgen_agent_runtime/tools/built_in/tool_batch_tool.py index 7d8ae17..d8bda3d 100644 --- a/src/xgen_agent_runtime/tools/built_in/tool_batch_tool.py +++ b/src/xgen_agent_runtime/tools/built_in/tool_batch_tool.py @@ -205,7 +205,7 @@ async def _one(tc: Dict[str, Any]) -> Dict[str, Any]: cached = repeat_guard.skip_identical(tc, shared) if cached is not None: return cached - _emit_call_start(emit, tc) + _emit_call_start(emit, tc, registry) t0 = time.monotonic() try: res = await router.route(tool_name, tc["tool_input"], context) @@ -215,7 +215,7 @@ async def _one(tc: Dict[str, Any]) -> Dict[str, Any]: result = res.to_api_format(tc["tool_use_id"]) if not isinstance(result.get("content"), str): result["content"] = _text_of(res) - _emit_call_complete(emit, tc, result, int((time.monotonic() - t0) * 1000)) + _emit_call_complete(emit, tc, result, int((time.monotonic() - t0) * 1000), registry) return result results = await asyncio.gather(*(_one(tc) for tc in calls)) diff --git a/src/xgen_agent_runtime/tools/mcp/adapter.py b/src/xgen_agent_runtime/tools/mcp/adapter.py index 9c1f897..50a8f1b 100644 --- a/src/xgen_agent_runtime/tools/mcp/adapter.py +++ b/src/xgen_agent_runtime/tools/mcp/adapter.py @@ -15,7 +15,7 @@ MCP_TOOL_PREFIX = "mcp__" -def _annotations_to_capabilities(annotations: Dict[str, Any]) -> ToolCapabilities: +def annotations_to_capabilities(annotations: Dict[str, Any]) -> ToolCapabilities: """Translate MCP tool annotations into :class:`ToolCapabilities`. The MCP spec defines four optional behavioural hints on each tool's @@ -113,11 +113,11 @@ def capabilities(self, input: Dict[str, Any]) -> ToolCapabilities: Lets PartitionExecutor / StreamingToolExecutor parallelise read-only MCP tools instead of treating every external tool - as fail-closed unsafe. See ``_annotations_to_capabilities`` + as fail-closed unsafe. See ``annotations_to_capabilities`` for the mapping. """ annotations = self._definition.get("annotations") or {} - return _annotations_to_capabilities(annotations) + return annotations_to_capabilities(annotations) async def execute(self, input: Dict[str, Any], context: ToolContext) -> ToolResult: """Execute the tool via the MCP server. diff --git a/tests/test_tool_facts.py b/tests/test_tool_facts.py new file mode 100644 index 0000000..485c9a7 --- /dev/null +++ b/tests/test_tool_facts.py @@ -0,0 +1,304 @@ +"""도구 호출의 능력 · 출처(capabilities · origin)와 그것을 싣는 tool.call_start / tool.call_complete.""" + +from __future__ import annotations + +import asyncio +import tempfile +import time +from pathlib import Path +from typing import Any, Dict, List + +import pytest +from langchain_core.tools import StructuredTool + +from xgen_agent_runtime.host.device_tools import build_device_tool, capabilities_from_annotations +from xgen_agent_runtime.host.tool_exposure import TURN_ONE_DISCOVERY, TURN_ONE_TOOLS +from xgen_agent_runtime.host.tools import adapt_tools +from xgen_agent_runtime.memory.provider import MemoryHooks, NoteDraft, Scope, provider_hooks +from xgen_agent_runtime.memory.providers.file.provider import FileMemoryProvider +from xgen_agent_runtime.memory.retriever import MemoryAwareRetriever +from xgen_agent_runtime.stages.s10_tool.artifact.default.executors import PartitionExecutor, SequentialExecutor +from xgen_agent_runtime.stages.s10_tool.artifact.default.routers import RegistryRouter +from xgen_agent_runtime.tools.base import Tool, ToolCapabilities, ToolContext, ToolResult, tool_origin +from xgen_agent_runtime.tools.built_in.bash_tool import BashTool, classify_shell_command +from xgen_agent_runtime.tools.registry import ToolRegistry + + +# ── Bash: 명령마다 (실패-닫힘) ── + +READ_ONLY_COMMANDS = [ + "LANG=C ls", + "LC_ALL=C sort f", + "less f", + "rg x", + "man ls", + "xxd f", + "tree -L 2", + "ls -la", + "cat a.csv | wc -l", + "grep -n foo *.py", + "python -c 'print(1 > 0)'", + "wc -l data.csv 2>/dev/null", + "echo hi", + "find . -name '*.md'", + "awk '{print $1}' f.txt", + "sed -n '1,10p' f", + "git log --oneline -5", + "git branch -a", + "git config --get user.name", + "docker ps --format '{{.Names}}'", + "cd /tmp && ls", + "head -3 f.csv >&2", + "curl -s https://x/y", + "wget -qO- https://x", + "sort -u f", + "uniq f", + "date", + "kubectl get pods", + "pip list", + "tar -tzf a.tar.gz", + "env", + "timeout 5 cat f", + "Get-ChildItem -Recurse | Select-Object Name", + "grep 'DELETE FROM' app.log", + "echo 'rm -rf /'", + "cat < report.md", + "cat a | tee out.md", + "rm -rf build", + "mkdir x", + "sed -i 's/a/b/' f.txt", + "sed 'w out' f", + "pip install pandas", + "git commit -am x", + "python -c \"df.to_excel('o.xlsx')\"", + "python summarize.py", + "find . -name '*.py' | xargs sed -i 's/a/b/'", + "perl -pi -e 's/a/b/' f", + "node -e \"require('fs').writeFileSync('x','1')\"", + "bash -c \"touch x\"", + "env sh -c 'touch x'", + "pwsh -Command \"Remove-Item x\"", + "echo $(touch x)", + "ls \"$(mkdir y)\"", + "grep foo f 2> err.txt", + "echo hi 1> out.txt", + "echo x &> out.log", + "ls >| out", + "sort -o out in", + "uniq in out", + "iconv -f utf8 -t ascii -o out in", + "yq -i '.a=1' f", + "find . -fls out", + "awk 'BEGIN{system(\"touch x\")}'", + "git log --output=x", + "git branch new", + "git reflog expire --expire=now --all", + "tar --delete -f a.tar m", + "ip route del default", + "mount -o remount,rw /", + "date -s '2020-01-01'", + "hostname x", + "npm config set registry x", + "go env -w X=1", + "curl -X POST https://x", + "curl --data a=1 https://x", + "curl -sSLo file https://x", + "wget https://x/file", + "ssh host 'touch x'", + "Invoke-RestMethod -Method Post https://x", + "Get-ChildItem | ForEach-Object { Remove-Item $_ }", + "sudo cat /etc/shadow", + "{ reboot; }", + "bash <<'EOF'\nrm -rf /\nEOF", + "python3 - <<'EOF'\nimport os\nos.remove('x')\nEOF", + "cat < out.txt\nhi\nEOF", + "ls 'unterminated", +] + + +@pytest.mark.parametrize("command", READ_ONLY_COMMANDS) +def test_bash_read_only_commands(command: str) -> None: + assert classify_shell_command(command).read_only, command + + +@pytest.mark.parametrize("command", NOT_READ_ONLY_COMMANDS) +def test_bash_commands_that_write_or_run_code_are_not_read_only(command: str) -> None: + assert not classify_shell_command(command).read_only, command + + +def test_bash_reports_only_read_only_and_egress() -> None: + for command in READ_ONLY_COMMANDS + NOT_READ_ONLY_COMMANDS: + caps = classify_shell_command(command) + assert not caps.destructive and not caps.concurrency_safe and not caps.idempotent, command + assert classify_shell_command("curl -s https://x/y").network_egress + assert classify_shell_command("pip install x").network_egress + assert classify_shell_command("git clone https://x").network_egress + assert classify_shell_command("python -c 'import requests; requests.get(1)'").network_egress + for command in ("kubectl get pods", "npm view x", "pip download x", "docker pull x"): + assert classify_shell_command(command).network_egress, command + assert not classify_shell_command("ls").network_egress + assert not classify_shell_command("pip list").network_egress + assert BashTool().capabilities({"command": "ls"}).read_only + + +def test_bash_classification_is_bounded() -> None: + for big in ("open(" * 20000, "Remove-Item " * 20000, "x" * 1_000_000): + t0 = time.monotonic() + assert not classify_shell_command(big).read_only + assert time.monotonic() - t0 < 1.0 + + +# ── 기기 도구: MCP 주석 ── + +def test_device_annotations_become_read_only_and_egress() -> None: + assert capabilities_from_annotations(None) is None + assert capabilities_from_annotations({}) is None + ro = capabilities_from_annotations({"readOnlyHint": True, "openWorldHint": False}) + assert ro.read_only and not ro.network_egress + assert not ro.concurrency_safe and not ro.destructive, "병렬 · 파괴는 바꾸지 않는다" + assert not capabilities_from_annotations({"readOnlyHint": True}).network_egress, "MCP 어댑터와 같은 규칙" + assert capabilities_from_annotations({"readOnlyHint": True, "idempotentHint": True}).idempotent + rw = capabilities_from_annotations({"readOnlyHint": False, "destructiveHint": True, "openWorldHint": False}) + assert not rw.read_only and not rw.destructive + + async def _call(tool, args): + return {"content": [{"type": "text", "text": "x"}]} + + read = build_device_tool(server="local", tool="ReadFile", description="d", input_schema={}, call=_call, + annotations={"readOnlyHint": True, "openWorldHint": False}) + shell = build_device_tool(server="local", tool="Shell", description="d", input_schema={}, call=_call) + assert read.capabilities({}).read_only and not shell.capabilities({}).read_only + assert tool_origin(read) == "device" + + +# ── 포트 도구: 노드가 적은 능력 ── + +def test_port_tool_capabilities_from_metadata() -> None: + get = StructuredTool.from_function(func=lambda q="": "ok", name="rates_api", description="GET rates") + get.metadata = {"read_only": True, "network_egress": True} + post = StructuredTool.from_function(func=lambda q="": "ok", name="order_api", description="POST order") + post.metadata = {"read_only": False, "network_egress": True} + plain = StructuredTool.from_function(func=lambda q="": "ok", name="plain", description="no metadata") + other = StructuredTool.from_function(func=lambda q="": "ok", name="other", description="other metadata") + other.metadata = {"opens_family": "web"} + reg = adapt_tools([get, post, plain, other, {"name": "calc", "func": lambda **_: 1, "description": "c", + "capabilities": {"read_only": True}}]) + assert reg.get("rates_api").capabilities({}).read_only and reg.get("rates_api").capabilities({}).network_egress + assert not reg.get("rates_api").capabilities({}).concurrency_safe, "병렬은 바꾸지 않는다" + assert not reg.get("order_api").capabilities({}).read_only + assert not reg.get("plain").capabilities({}).read_only, "모르면 쓰기 가능(실패-닫힘)" + assert not reg.get("other").capabilities({}).read_only, "read_only 를 적지 않은 메타데이터는 능력이 아니다" + assert reg.get("calc").capabilities({}).read_only + assert tool_origin(reg.get("rates_api")) == "adapted" + + +# ── 실행기: 사건에 성격 ── + +class _RoTool(Tool): + input_schema = {"type": "object", "properties": {}} + + def __init__(self, name: str, read_only: bool) -> None: + self._name, self._ro = name, read_only + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return "t" + + def capabilities(self, input): + return ToolCapabilities(concurrency_safe=self._ro, read_only=self._ro, idempotent=self._ro) + + async def execute(self, input, context): # noqa: A002 + return ToolResult(content="ok") + + +@pytest.mark.parametrize("executor", ["partition", "sequential"]) +def test_executor_events_carry_capabilities_and_origin(executor: str) -> None: + reg = ToolRegistry() + reg.register(_RoTool("look", True)) + reg.register(_RoTool("make", False)) + router = RegistryRouter(reg) + events: List[tuple[str, Dict[str, Any]]] = [] + calls = [{"tool_use_id": "t1", "tool_name": "look", "tool_input": {}}, + {"tool_use_id": "t2", "tool_name": "make", "tool_input": {}}] + ex = PartitionExecutor(registry=reg) if executor == "partition" else SequentialExecutor() + asyncio.run(ex.execute_all(calls, router, ToolContext(), on_event=lambda e, p: events.append((e, p)))) + starts = {p["name"]: p for e, p in events if e == "tool.call_start"} + ends = {p["name"]: p for e, p in events if e == "tool.call_complete"} + assert starts["look"]["capabilities"]["read_only"] is True and starts["make"]["capabilities"]["read_only"] is False + assert ends["look"]["capabilities"]["read_only"] is True + assert "origin" in starts["look"] + + +# ── 턴 1 표: 도구 발견 ── + +def test_turn_one_discovery_is_part_of_the_table() -> None: + assert "ToolSearch" in TURN_ONE_DISCOVERY + assert TURN_ONE_DISCOVERY <= TURN_ONE_TOOLS + assert "ToolBatch" in TURN_ONE_TOOLS and "ToolBatch" not in TURN_ONE_DISCOVERY + + +# ── 검색기 hooks ── + +def test_provider_hooks_reads_what_set_hooks_stored() -> None: + class _P: + def __init__(self): + self._hooks = None + + def set_hooks(self, hooks): + self._hooks = hooks + + p = _P() + assert provider_hooks(p) is None + p.set_hooks(MemoryHooks(search_exclude_categories=("memory-map",))) + assert provider_hooks(p).search_exclude_categories == ("memory-map",) + + class _NotHooks: + max_results = 3 + + p._hooks = _NotHooks() + assert provider_hooks(p) is None, "MemoryHooks 가 아니면 쓰지 않는다" + + +@pytest.mark.asyncio +async def test_excluded_categories_do_not_crowd_out_the_top_k() -> None: + # 뺀 분류(대화 기록)가 저장소의 대부분이어도 나머지 노트가 결과에 남는다 + with tempfile.TemporaryDirectory() as td: + p = FileMemoryProvider(root=Path(td), scope=Scope.SESSION) + await p.initialize() + for i in range(8): + await p.notes().write(NoteDraft(title=f"대화 {i}", body="드보트 드보트 드보트 대화 기록", category="conversations", + filename=f"c{i}.md")) + await p.notes().write(NoteDraft(title="규정", body="드보트 규정", category="topics", filename="t.md")) + hooks = MemoryHooks(max_results=2, search_exclude_categories=("conversations",)) + pf = await MemoryAwareRetriever(p, hooks=hooks)._prefetch_layers("드보트", hooks) + kw = pf.get("kw_notes") or [] + cats = [(c.metadata or {}).get("category") for c in kw] + assert "conversations" not in cats + assert "topics" in cats + assert len(kw) <= 2 diff --git a/tests/unit/test_mcp_annotations_attach.py b/tests/unit/test_mcp_annotations_attach.py index 017c4b6..0a7c2fb 100644 --- a/tests/unit/test_mcp_annotations_attach.py +++ b/tests/unit/test_mcp_annotations_attach.py @@ -9,7 +9,7 @@ from xgen_agent_runtime.tools.base import ToolCapabilities from tests._fixtures.manifest_entries import required_stage_entries -from xgen_agent_runtime.tools.mcp.adapter import MCPToolAdapter, _annotations_to_capabilities +from xgen_agent_runtime.tools.mcp.adapter import MCPToolAdapter, annotations_to_capabilities from xgen_agent_runtime.tools.mcp.manager import ( MCPServerConfig, MCPServerConnection, @@ -25,7 +25,7 @@ class TestAnnotationsToCapabilities: def test_empty_falls_back_to_default(self): - caps = _annotations_to_capabilities({}) + caps = annotations_to_capabilities({}) # Default is fail-closed: not concurrency-safe. assert caps.concurrency_safe is False assert caps.read_only is False @@ -34,29 +34,29 @@ def test_empty_falls_back_to_default(self): assert caps.network_egress is False def test_read_only_implies_concurrency_safe(self): - caps = _annotations_to_capabilities({"readOnlyHint": True}) + caps = annotations_to_capabilities({"readOnlyHint": True}) assert caps.read_only is True assert caps.concurrency_safe is True def test_destructive_overrides_read_only(self): """If a server inconsistently sets BOTH readOnly and destructive, we must err on the side of caution and serialise.""" - caps = _annotations_to_capabilities( + caps = annotations_to_capabilities( {"readOnlyHint": True, "destructiveHint": True} ) assert caps.destructive is True assert caps.concurrency_safe is False def test_idempotent_propagates(self): - caps = _annotations_to_capabilities({"idempotentHint": True}) + caps = annotations_to_capabilities({"idempotentHint": True}) assert caps.idempotent is True def test_open_world_means_network_egress(self): - caps = _annotations_to_capabilities({"openWorldHint": True}) + caps = annotations_to_capabilities({"openWorldHint": True}) assert caps.network_egress is True def test_full_combination(self): - caps = _annotations_to_capabilities( + caps = annotations_to_capabilities( { "readOnlyHint": True, "idempotentHint": True,