Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 도구 사이 진행 메모가 영어로 나오던 것
Expand Down
8 changes: 6 additions & 2 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
<!-- AUTO-GENERATED — do not edit by hand. -->
<!-- Regenerate: python scripts/gen_event_docs.py -->

> 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
Expand Down Expand Up @@ -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`

Expand All @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion src/xgen_agent_runtime/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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),
)

Expand Down
7 changes: 6 additions & 1 deletion src/xgen_agent_runtime/events/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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: {
Expand Down
20 changes: 19 additions & 1 deletion src/xgen_agent_runtime/host/device_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -150,13 +151,28 @@ 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,
tool: str,
description: str,
input_schema: Any,
call: DeviceCall,
annotations: Any = None,
) -> Tool:
"""기기 카탈로그 한 줄 → 런타임 도구. 모델 이름은 :func:`model_tool_name` 이 정한다."""
name = model_tool_name(server, tool)
Expand All @@ -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",
)
Expand Down Expand Up @@ -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",
]
3 changes: 3 additions & 0 deletions src/xgen_agent_runtime/host/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...
20 changes: 19 additions & 1 deletion src/xgen_agent_runtime/host/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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=[
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand Down
5 changes: 4 additions & 1 deletion src/xgen_agent_runtime/host/tool_exposure.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ def sends_every_schema(value: object) -> bool:
# 메모리·작업·위임·커넥터)에 흩어져 있고, 각자가 자기 판단으로 ``core=True`` 를
# 쓰면 표면은 아무도 의도하지 않은 모양이 된다.

#: 도구를 찾기만 하는 호출(실제 작업이 아니다).
TURN_ONE_DISCOVERY = frozenset({"ToolSearch"})

#: 첫 턴에 스키마까지 보이는 도구.
#:
#: 각 줄은 **입구 하나**다. 패밀리 전체를 올리는 줄은 없다 — 기본 명령만 예외인데,
Expand All @@ -86,7 +89,7 @@ def sends_every_schema(value: object) -> bool:
"Glob",
"Grep",
# 2. 도구 발견 — 아래 계층 전부로 가는 문.
"ToolSearch",
*TURN_ONE_DISCOVERY,
# 2-b. 목록 작업 — 같은 도구를 여러 입력으로 한 왕복에. 첫 턴에 보여야
# 항목마다 따로 부르는 습관(왕복 N회)이 처음부터 생기지 않는다.
"ToolBatch",
Expand Down
21 changes: 19 additions & 2 deletions src/xgen_agent_runtime/host/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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",
)

Expand Down
25 changes: 25 additions & 0 deletions src/xgen_agent_runtime/host/turn_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import inspect
import logging
import os
from dataclasses import replace
Expand Down Expand Up @@ -201,6 +202,29 @@ def _model_vision(provider: str, model: str, override: Any = None) -> bool:
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:
"""execute() 의 host-무관 판. 서버·커넥터가 같은 run() 을 돈다."""

Expand Down Expand Up @@ -409,6 +433,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",
Expand Down
9 changes: 9 additions & 0 deletions src/xgen_agent_runtime/memory/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ─────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading