From aafc6ac12f2dcfe42a7e1406618498b82a4cb673 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 11 Oct 2026 19:28:19 +0800 Subject: [PATCH 1/3] feat: define typed tool result contracts --- .../plans/2026-10-11-v3-issue-235.md | 76 ++++++++++++ openkyrozen/tools/__init__.py | 2 +- openkyrozen/tools/models.py | 111 +++++++++++++++++- tests/test_tool_results.py | 57 +++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-11-v3-issue-235.md create mode 100644 tests/test_tool_results.py diff --git a/docs/superpowers/plans/2026-10-11-v3-issue-235.md b/docs/superpowers/plans/2026-10-11-v3-issue-235.md new file mode 100644 index 0000000..837cced --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-v3-issue-235.md @@ -0,0 +1,76 @@ +# V3 Issue #235 Typed Tool Results Implementation Plan + +> **For agentic workers:** Execute this plan task by task with test-first changes and a whole-branch independent review before delivery. + +**Goal:** Complete R024, R025 and R031: tool outcomes and stable failures are machine-readable, while legacy text output is a presentation-only view. + +**Architecture:** Add immutable `ToolError`/`ToolResult` contracts and a single renderer. Bind typed executors in each workspace registry while preserving text-facing built-in methods and mappings. Make the runtime consume `ToolResult` for receipts, observers, learning and MCP status; preserve existing authorization, scope and evidence gates. + +**Tech Stack:** Python 3.12–3.13; stdlib dataclasses/JSON; current unittest suite; no new dependencies or paid provider calls. + +**Spec:** GitHub issue #235 and the supplied OpenKyrozen V3 architecture proposal (R024, R025, R031); only #235 is in scope. + +## Global Constraints + +- Preserve `CommandResult`, built-in text APIs, MCP text content, approval requirements, workspace and subagent boundaries, usage accounting, receipt/evidence verification, and canonical registry metadata. +- Native typed argument migration, provider serialization, receipt identity expansion, parallel scheduling and retries stay in their assigned issues. +- A custom/dynamic executor must return `ToolResult`; invalid return values fail terminally with `contract_error` after one invocation. +- Never infer success from result text. Child work status is distinct from orchestration-call status. + +## Review Focus + +- Successful content beginning with `Error:` remains successful; failed Git/command results remain failed even when their text omits error markers. +- Exceptions and returned `ToolError` retain stable codes without exposing credentials or request bodies. +- A tool that mutates state and returns an invalid result is not run a second time. +- Child failure reports do not make an orchestration query itself fail; malformed coordinator requests do. +- Changing display rendering cannot change authorization, receipts, observer events, learning statistics, MCP `isError`, or plan acceptance. + +--- + +### Task 1: Result and error contracts + +**Files:** `openkyrozen/tools/models.py`, `openkyrozen/tools/__init__.py`, `tests/test_tool_results.py`. + +- [ ] Write failing tests for frozen `ToolError`/`ToolResult`, `success`, required code/message, JSON-safe data/metadata, defensive copies, and render behavior. +- [ ] Verify RED, then implement immutable records, validation, and `render_tool_result(result) -> str` while retaining `CommandResult` unchanged. +- [ ] Define and document stable codes for invalid arguments, not found, permission/approval denial, unavailable dependency, timeout, cancellation, command failure, stale/conflict, execution error and contract error. Default retries to false; no retry behavior is added. +- [ ] Run `python -m unittest tests.test_tool_results -v`; expect all contract tests to pass. + +### Task 2: Typed registry bindings and tool producers + +**Files:** `openkyrozen/tools/manifest.py`, tool adapter modules, `openkyrozen/agent/runtime.py`, `openkyrozen/agent/delegation_runtime.py`, `openkyrozen/memory/retrieval.py`, `tests/test_tool_registry.py`, `tests/test_tools.py`. + +- [ ] Add failing tests proving all 48 canonical specs invoke typed executors; built-in method and `AVAILABLE_TOOLS` calls still render the same text; replacement and snapshot restore retain typed executor metadata. +- [ ] Add failure regressions for valid file content starting with `Error:`, failed Git/command status, malformed arguments, missing graph/browser/GitHub dependencies, stale edits, timeouts and execution exceptions. +- [ ] Migrate each producer at the point status is known. Preserve existing useful structured data and output text; convert `CommandResult.success/failure/exit_code` explicitly. Assign stable codes and never infer from displayed text. +- [ ] Represent delegation invocation success/failure separately from individual child statuses in structured data. +- [ ] Bind `ToolSpec.executor` to the typed implementation. Keep public built-in method/mapping compatibility wrappers render-only. Require custom/dynamic code to return `ToolResult`; expose constructors in its restricted namespace and preserve static validation, opt-in, approval, audit and no-source-persistence behavior. +- [ ] Add a terminal contract error for wrong return types and assert the callable is executed exactly once. +- [ ] Run focused tool, registry, dynamic-tool and delegation suites on Python 3.12. + +### Task 3: Runtime and surface status propagation + +**Files:** `openkyrozen/agent/executor.py`, `openkyrozen/agent/planner.py`, `openkyrozen/agent/subagent_runtime.py`, `openkyrozen/learning/failures.py`, `openkyrozen/interfaces/mcp/server.py`, CLI/Web render boundaries, relevant tests. + +- [ ] Write failing tests showing result wording cannot change receipts, plugin status, learning stats, MCP `isError`, or subagent success; cover actual tool errors despite friendly text. +- [ ] Refactor shared execution to consume `ToolResult` and derive receipt success/error code, explicit observer and learning status, and MCP error status from it. Keep receipt display strings and existing client-visible MCP/CLI/TUI/Web text stable. +- [ ] Preserve independent verification for effects and acceptance; successful tool execution alone must not complete a task. +- [ ] Ensure renderer changes affect only displayed text. Check all callers of `_is_tool_error`, `_track_tool_performance`, and result-rendering helpers; keep text inference only for external command-output acceptance checks where it evaluates the command's evidence, never tool execution status. +- [ ] Run focused executor, receipt, plugin, learning, MCP, subagent and surface tests on Python 3.12. + +### Task 4: Integration, docs and delivery + +**Files:** `docs/extensions.md`, generated/API docs as needed, focused and integration tests. + +- [ ] Document the result contract, stable codes, custom/dynamic migration, text compatibility and non-retriable mutation errors; link any new document from `docs/index.md`. +- [ ] Run focused suites on Python 3.12 and 3.13; then `make test`, `make check`, `make lint`, `make docs-check`, and offline agent/subagent acceptance. +- [ ] Obtain independent review. Reproduce each reported issue, fix material findings test-first, and resolve fixed review threads. +- [ ] Create one GPG-signed PR, verify signatures locally and on GitHub, attach it, and wait for final-head CI/review. Leave the PR open and unmerged. + +## Assumptions and defaults + +Built-in string contracts remain available to current direct callers; internal registry execution is typed. Unknown custom/dynamic return types fail closed after one call. `retriable` defaults false, with no automatic retry in this issue. Tool success, child-task success and verified acceptance remain three separate states. + +## Interface and compatibility + +`ToolResult` exposes `data`, `text`, optional `ToolError`, metadata and derived `success`; `ToolError` exposes stable `code`, `message`, `retriable` and metadata. Existing built-in string methods and `AVAILABLE_TOOLS` calls continue through rendering wrappers. Custom/dynamic string-returning callables must migrate to `ToolResult`. Existing receipt/client text fields remain compatible; machine status comes from the typed result. diff --git a/openkyrozen/tools/__init__.py b/openkyrozen/tools/__init__.py index d17b318..8a4d7a9 100644 --- a/openkyrozen/tools/__init__.py +++ b/openkyrozen/tools/__init__.py @@ -1,5 +1,5 @@ """Tool contracts and adapter construction.""" -from .models import CommandResult +from .models import CommandResult, ToolError, ToolResult, render_tool_result from .adapters import ToolAdapters from openkyrozen.security.tool_policy import allowed_tool_names, resolve_capabilities, tool_capability diff --git a/openkyrozen/tools/models.py b/openkyrozen/tools/models.py index 876be2a..446c538 100644 --- a/openkyrozen/tools/models.py +++ b/openkyrozen/tools/models.py @@ -1,6 +1,115 @@ from __future__ import annotations -from dataclasses import dataclass +import json +import math +from dataclasses import dataclass, field +from typing import Any + + +def _validate_json(value: Any) -> None: + if isinstance(value, dict): + if any(not isinstance(key, str) for key in value): + raise TypeError("JSON object keys must be strings") + for item in value.values(): + _validate_json(item) + elif isinstance(value, list): + for item in value: + _validate_json(item) + elif value is None or type(value) in {str, int, bool}: + return + elif type(value) is float and math.isfinite(value): + return + else: + raise TypeError("Value is not JSON-safe") + + +def _json_object(value: Any, name: str) -> str: + if not isinstance(value, dict): + raise TypeError(f"{name} must be a JSON object") + try: + _validate_json(value) + return json.dumps(value, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise TypeError(f"{name} must contain only JSON-safe values") from exc + + +@dataclass(frozen=True, init=False) +class ToolError: + """A stable machine-readable tool failure; metadata contains JSON-safe diagnostics.""" + + code: str + message: str + retriable: bool + _metadata_json: str = field(repr=False) + + def __init__(self, code: str, message: str, retriable: bool = False, + metadata: dict[str, Any] | None = None) -> None: + if not isinstance(code, str) or not code.strip(): + raise ValueError("ToolError code must be a nonempty string") + if not isinstance(message, str) or not message.strip(): + raise ValueError("ToolError message must be a nonempty string") + if type(retriable) is not bool: + raise TypeError("ToolError retriable must be a boolean") + object.__setattr__(self, "code", code) + object.__setattr__(self, "message", message) + object.__setattr__(self, "retriable", retriable) + object.__setattr__(self, "_metadata_json", _json_object({} if metadata is None else metadata, "ToolError metadata")) + + @property + def metadata(self) -> dict[str, Any]: + return json.loads(self._metadata_json) + + +@dataclass(frozen=True, init=False) +class ToolResult: + """A tool's typed outcome with display text kept separate from its status.""" + + _data_json: str | None = field(repr=False) + text: str + error: ToolError | None + _metadata_json: str = field(repr=False) + + def __init__(self, data: Any = None, text: str = "", error: ToolError | None = None, + metadata: dict[str, Any] | None = None) -> None: + if not isinstance(text, str): + raise TypeError("ToolResult text must be a string") + if error is not None and not isinstance(error, ToolError): + raise TypeError("ToolResult error must be ToolError or None") + try: + _validate_json(data) + data_json = json.dumps(data, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise TypeError("ToolResult data must be JSON-safe") from exc + object.__setattr__(self, "_data_json", data_json) + object.__setattr__(self, "text", text) + object.__setattr__(self, "error", error) + object.__setattr__(self, "_metadata_json", _json_object({} if metadata is None else metadata, "ToolResult metadata")) + + @property + def success(self) -> bool: + return self.error is None + + @property + def data(self) -> Any: + return json.loads(self._data_json) if self._data_json is not None else None + + @property + def metadata(self) -> dict[str, Any]: + return json.loads(self._metadata_json) + + +def render_tool_result(result: ToolResult) -> str: + """Render a typed outcome for legacy text clients without changing its status.""" + if not isinstance(result, ToolResult): + raise TypeError("Expected ToolResult") + if result.text: + return result.text + if result.error is not None: + return f"Error: {result.error.message}" + if result.data is not None: + return json.dumps(result.data, ensure_ascii=False) + return "" + @dataclass(frozen=True) class CommandResult: diff --git a/tests/test_tool_results.py b/tests/test_tool_results.py new file mode 100644 index 0000000..f75453b --- /dev/null +++ b/tests/test_tool_results.py @@ -0,0 +1,57 @@ +import dataclasses +import unittest + +from openkyrozen.tools import models + + +ToolError = getattr(models, "ToolError", None) +ToolResult = getattr(models, "ToolResult", None) +render_tool_result = getattr(models, "render_tool_result", None) + + +class ToolResultContractTests(unittest.TestCase): + def require_api(self): + self.assertTrue(all((ToolError, ToolResult, render_tool_result)), "typed tool result API is missing") + if not all((ToolError, ToolResult, render_tool_result)): + self.skipTest("typed tool result API is not implemented yet") + + def test_success_depends_on_typed_error_not_display_text(self): + self.require_api() + success = ToolResult(data={"contents": "Error: this is a valid file"}, text="Error: this is a valid file") + failure = ToolResult(data={"partial": True}, text="Completed", error=ToolError("not_found", "Missing file")) + self.assertTrue(success.success) + self.assertFalse(failure.success) + + def test_contracts_are_frozen_and_return_defensive_json_copies(self): + self.require_api() + source = {"nested": [1]} + result = ToolResult(data=source, metadata={"audit": {"tags": ["safe"]}}) + source["nested"].append(2) + observed = result.data + observed["nested"].append(3) + observed_meta = result.metadata + observed_meta["audit"]["tags"].append("changed") + self.assertEqual(result.data, {"nested": [1]}) + self.assertEqual(result.metadata, {"audit": {"tags": ["safe"]}}) + with self.assertRaises(dataclasses.FrozenInstanceError): + result.text = "mutated" + with self.assertRaises(dataclasses.FrozenInstanceError): + result.error = ToolError("x", "y") + + def test_invalid_code_message_and_non_json_values_are_rejected(self): + self.require_api() + for kwargs in ({"code": "", "message": "bad"}, {"code": "execution_error", "message": " "}): + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + ToolError(**kwargs) + with self.assertRaises((TypeError, ValueError)): + ToolResult(data={"bad": object()}) + with self.assertRaises((TypeError, ValueError)): + ToolResult(metadata={"bad": float("nan")}) + with self.assertRaises(TypeError): + ToolResult(text=123) + + def test_renderer_preserves_text_and_renders_structured_fallback(self): + self.require_api() + self.assertEqual(render_tool_result(ToolResult(text="Error: valid text", error=None)), "Error: valid text") + self.assertEqual(render_tool_result(ToolResult(error=ToolError("not_found", "Missing"))), "Error: Missing") + self.assertEqual(render_tool_result(ToolResult(data={"count": 2})), '{"count": 2}') From cf6ff8c04ffc1096e5f3ce8f65a5f2b955e51003 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 11 Oct 2026 20:19:32 +0800 Subject: [PATCH 2/3] feat: propagate typed tool results --- docs/extensions.md | 5 +- docs/index.md | 1 + .../plans/2026-10-11-v3-issue-235.md | 41 ++--- openkyrozen/agent/delegation_runtime.py | 23 ++- openkyrozen/agent/executor.py | 161 ++++++++---------- openkyrozen/agent/initial_response.py | 5 +- openkyrozen/agent/prompts.py | 12 +- openkyrozen/agent/subagent_runtime.py | 28 ++- openkyrozen/agent/types.py | 2 + openkyrozen/interfaces/mcp/server.py | 9 +- openkyrozen/learning/evolution.py | 4 +- openkyrozen/learning/failures.py | 3 +- openkyrozen/memory/retrieval.py | 19 ++- openkyrozen/tools/__init__.py | 2 +- openkyrozen/tools/browser.py | 17 +- openkyrozen/tools/browser_manager.py | 44 ++--- openkyrozen/tools/calculation.py | 6 +- openkyrozen/tools/dynamic.py | 5 +- openkyrozen/tools/filesystem.py | 86 +++++----- openkyrozen/tools/git.py | 135 ++++++++------- openkyrozen/tools/github.py | 14 +- openkyrozen/tools/github_cli.py | 31 ++-- openkyrozen/tools/graph.py | 26 +-- openkyrozen/tools/manifest.py | 34 +++- openkyrozen/tools/models.py | 64 +++++++ openkyrozen/tools/remote.py | 11 +- openkyrozen/tools/search.py | 19 ++- openkyrozen/tools/shell.py | 5 +- openkyrozen/tools/web.py | 19 ++- tests/test_architecture.py | 3 +- tests/test_browser_manager.py | 20 +++ tests/test_capabilities.py | 10 +- tests/test_dynamic_tools.py | 14 +- tests/test_github_cli.py | 9 +- tests/test_interaction.py | 5 +- tests/test_learning_dispatcher.py | 7 +- tests/test_task_consistency.py | 7 +- tests/test_tool_discovery.py | 3 +- tests/test_tool_registry.py | 22 ++- tests/test_tool_results.py | 93 ++++++++++ 40 files changed, 659 insertions(+), 365 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 54a7717..d06c08c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -20,7 +20,7 @@ The plugin lifecycle supports project Python extensions such as the sample `plug ## Dynamic tools -Runtime-created dynamic tools are a distinct feature from skills. They remain disabled unless the operator enables them with `KYROZEN_ALLOW_DYNAMIC_TOOLS` and all normal tool capability and approval checks still apply. Review generated source and permissions before activation. A learning feature that observes successful tools does not silently switch this policy on. +Runtime-created dynamic tools are a distinct feature from skills. They remain disabled unless the operator enables them with `KYROZEN_ALLOW_DYNAMIC_TOOLS` and all normal tool capability and approval checks still apply. Dynamic executors must return `ToolResult`; the restricted source namespace provides `ToolResult` and `ToolError` constructors. Review generated source and permissions before activation. A learning feature that observes successful tools does not silently switch this policy on. ## Learned policies and skills @@ -63,3 +63,6 @@ Registration validates the supported object-schema vocabulary, including nested properties/items/combinators and scalar constraints. Unsupported keywords are rejected rather than forwarded unchecked; this registry is not a general JSON Schema evaluation engine. Existing legacy argument conversion remains unchanged. + + +Built-in tools use stable result codes such as `invalid_arguments`, `not_found`, `permission_denied`, `approval_denied`, `dependency_unavailable`, `timeout`, `cancelled`, `command_failed`, `stale_file`, `conflict`, `execution_error`, and `contract_error`. Mutating tools keep failures non-retriable unless retry safety is explicitly established. diff --git a/docs/index.md b/docs/index.md index e71551a..74b8a38 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,7 @@ This index covers the current product guides, developer references, and dated en - [V3 provider contract plan](superpowers/plans/2026-10-08-v3-issue-228.md) records the structured response and compatibility migration for issue #228. - [V3 provider error plan](superpowers/plans/2026-10-09-v3-issue-231.md) records typed failures, cancellation and bounded retry for issue #231. - [V3 authoritative tool catalog plan](superpowers/plans/2026-10-09-v3-issue-232.md) records registry ownership and schema/policy migration for issue #232. +- [V3 typed tool results plan](superpowers/plans/2026-10-11-v3-issue-235.md) records typed execution outcomes, error codes, observer propagation, and compatibility for issue #235. - Machine-readable audit receipts and benchmark inputs remain beside their corresponding reports under `docs/` and `docs/benchmarks/`. The repository's [English overview](../README.md) links here. Technical guides are maintained in English; README translations provide localized entry points. diff --git a/docs/superpowers/plans/2026-10-11-v3-issue-235.md b/docs/superpowers/plans/2026-10-11-v3-issue-235.md index 837cced..842d2f6 100644 --- a/docs/superpowers/plans/2026-10-11-v3-issue-235.md +++ b/docs/superpowers/plans/2026-10-11-v3-issue-235.md @@ -31,41 +31,42 @@ **Files:** `openkyrozen/tools/models.py`, `openkyrozen/tools/__init__.py`, `tests/test_tool_results.py`. -- [ ] Write failing tests for frozen `ToolError`/`ToolResult`, `success`, required code/message, JSON-safe data/metadata, defensive copies, and render behavior. -- [ ] Verify RED, then implement immutable records, validation, and `render_tool_result(result) -> str` while retaining `CommandResult` unchanged. -- [ ] Define and document stable codes for invalid arguments, not found, permission/approval denial, unavailable dependency, timeout, cancellation, command failure, stale/conflict, execution error and contract error. Default retries to false; no retry behavior is added. -- [ ] Run `python -m unittest tests.test_tool_results -v`; expect all contract tests to pass. +- [x] Write failing tests for frozen `ToolError`/`ToolResult`, `success`, required code/message, JSON-safe data/metadata, defensive copies, and render behavior. +- [x] Verify RED, then implement immutable records, validation, and `render_tool_result(result) -> str` while retaining `CommandResult` unchanged. +- [x] Define and document stable codes for invalid arguments, not found, permission/approval denial, unavailable dependency, timeout, cancellation, command failure, stale/conflict, execution error and contract error. Default retries to false; no retry behavior is added. +- [x] Run the focused tool-result contract tests; all pass on Python 3.12. ### Task 2: Typed registry bindings and tool producers **Files:** `openkyrozen/tools/manifest.py`, tool adapter modules, `openkyrozen/agent/runtime.py`, `openkyrozen/agent/delegation_runtime.py`, `openkyrozen/memory/retrieval.py`, `tests/test_tool_registry.py`, `tests/test_tools.py`. -- [ ] Add failing tests proving all 48 canonical specs invoke typed executors; built-in method and `AVAILABLE_TOOLS` calls still render the same text; replacement and snapshot restore retain typed executor metadata. -- [ ] Add failure regressions for valid file content starting with `Error:`, failed Git/command status, malformed arguments, missing graph/browser/GitHub dependencies, stale edits, timeouts and execution exceptions. -- [ ] Migrate each producer at the point status is known. Preserve existing useful structured data and output text; convert `CommandResult.success/failure/exit_code` explicitly. Assign stable codes and never infer from displayed text. -- [ ] Represent delegation invocation success/failure separately from individual child statuses in structured data. -- [ ] Bind `ToolSpec.executor` to the typed implementation. Keep public built-in method/mapping compatibility wrappers render-only. Require custom/dynamic code to return `ToolResult`; expose constructors in its restricted namespace and preserve static validation, opt-in, approval, audit and no-source-persistence behavior. -- [ ] Add a terminal contract error for wrong return types and assert the callable is executed exactly once. -- [ ] Run focused tool, registry, dynamic-tool and delegation suites on Python 3.12. +- [x] Add regressions proving all 48 canonical specs bind typed executors; built-in text projections, registry metadata, and workspace isolation remain intact. +- [x] Add failure regressions for error-prefixed valid content, command status, browser snapshot failure, and invalid custom returns. +- [x] Migrate producers at the point status is known, preserving text and structured data and converting `CommandResult` fields explicitly. +- [x] Represent delegation invocation status separately from child statuses. +- [x] Bind typed executors in `ToolSpec`; preserve text projections and require typed custom/dynamic results in the existing restricted namespace. +- [x] Add terminal contract errors for wrong return types and assert single execution. +- [x] Run focused tool, registry, dynamic-tool, browser, and task suites on Python 3.12; 127 tests pass (one browser integration skip). ### Task 3: Runtime and surface status propagation **Files:** `openkyrozen/agent/executor.py`, `openkyrozen/agent/planner.py`, `openkyrozen/agent/subagent_runtime.py`, `openkyrozen/learning/failures.py`, `openkyrozen/interfaces/mcp/server.py`, CLI/Web render boundaries, relevant tests. -- [ ] Write failing tests showing result wording cannot change receipts, plugin status, learning stats, MCP `isError`, or subagent success; cover actual tool errors despite friendly text. -- [ ] Refactor shared execution to consume `ToolResult` and derive receipt success/error code, explicit observer and learning status, and MCP error status from it. Keep receipt display strings and existing client-visible MCP/CLI/TUI/Web text stable. -- [ ] Preserve independent verification for effects and acceptance; successful tool execution alone must not complete a task. -- [ ] Ensure renderer changes affect only displayed text. Check all callers of `_is_tool_error`, `_track_tool_performance`, and result-rendering helpers; keep text inference only for external command-output acceptance checks where it evaluates the command's evidence, never tool execution status. -- [ ] Run focused executor, receipt, plugin, learning, MCP, subagent and surface tests on Python 3.12. +- [x] Add regressions showing result wording cannot change receipt, plugin, or learning status. +- [x] Derive runtime receipt success and error code, observer and learning status, and MCP `isError` from typed outcomes while retaining text projections. +- [x] Preserve independent effect and acceptance verification. +- [x] Remove tool status classification from shared execution; leave unused legacy text helper for compatibility. +- [x] Run focused executor, receipt, plugin, learning, MCP, subagent and surface suites on Python 3.12. ### Task 4: Integration, docs and delivery **Files:** `docs/extensions.md`, generated/API docs as needed, focused and integration tests. -- [ ] Document the result contract, stable codes, custom/dynamic migration, text compatibility and non-retriable mutation errors; link any new document from `docs/index.md`. -- [ ] Run focused suites on Python 3.12 and 3.13; then `make test`, `make check`, `make lint`, `make docs-check`, and offline agent/subagent acceptance. -- [ ] Obtain independent review. Reproduce each reported issue, fix material findings test-first, and resolve fixed review threads. -- [ ] Create one GPG-signed PR, verify signatures locally and on GitHub, attach it, and wait for final-head CI/review. Leave the PR open and unmerged. +- [x] Document the typed result contract, stable codes, custom/dynamic migration, text compatibility and non-retriable mutation failures; index the implementation plan. +- [x] Python 3.12 `make test-core`: 625 passed, 14 optional skips. `make check`, `make lint`, `make docs-check`, and offline agent/subagent acceptance pass. +- [x] Python 3.13 focused tool/runtime suites: 123 pass; the three mocked browser manager tests also pass. Browser-enabled `make test` reached 625 tests but its three browser flows require the unavailable Chromium executable. +- [x] Independent review found a browser click/snapshot status-loss defect; fixed it and added a regression. Await automated PR reviews for any further findings. +- [ ] Create one signed PR, verify signatures locally and on GitHub, attach it, and wait for final-head CI/review. Leave the PR open and unmerged. ## Assumptions and defaults diff --git a/openkyrozen/agent/delegation_runtime.py b/openkyrozen/agent/delegation_runtime.py index ee39fab..94ab436 100644 --- a/openkyrozen/agent/delegation_runtime.py +++ b/openkyrozen/agent/delegation_runtime.py @@ -1,5 +1,6 @@ """Runtime integration for chat-owned, independently scoped delegation.""" from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import copy import json @@ -110,37 +111,47 @@ def _json_args(args): return value +@legacy_text_api def spawn_agents(self, args): """Automatically start parallel specialist assignments; args is a JSON assignment batch.""" if self.execution_context.child_run_id: raise ValueError("Only the main agent can delegate") - return json.dumps({"agents": self.delegation().spawn(_json_args(args).get("assignments"))}, ensure_ascii=False) + data = {"agents": self.delegation().spawn(_json_args(args).get("assignments"))} + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) +@legacy_text_api def send_subagent(self, args): """Reuse a finished sub-agent; args JSON contains run_id and a complete assignment.""" value = _json_args(args) - return json.dumps(self.delegation().send(value.get("run_id"), value.get("assignment")), ensure_ascii=False) + data = self.delegation().send(value.get("run_id"), value.get("assignment")) + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) +@legacy_text_api def list_subagents(self, args): """List agents or inspect one run_id from this chat/project; args is JSON.""" value = _json_args(args) coordinator = self.delegation() - return json.dumps(coordinator.detail(value["run_id"]) if value.get("run_id") else - {"agents": [coordinator.summary(run, results=True) for run in coordinator.list()]}, ensure_ascii=False) + data = coordinator.detail(value["run_id"]) if value.get("run_id") else { + "agents": [coordinator.summary(run, results=True) for run in coordinator.list()]} + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) +@legacy_text_api def wait_subagents(self, args): """Wait up to 60 seconds for delegated work and its mandatory reviews; args is JSON.""" value = _json_args(args) coordinator = self.delegation() - return json.dumps({"agents": [coordinator.summary(run, results=True) for run in coordinator.wait(value.get("run_ids"), value.get("timeout", 30))]}, ensure_ascii=False) + data = {"agents": [coordinator.summary(run, results=True) for run in coordinator.wait(value.get("run_ids"), value.get("timeout", 30))]} + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) +@legacy_text_api def cancel_subagent(self, args): """Cancel a scoped sub-agent and prevent subsequent tool execution; args JSON contains run_id.""" - return json.dumps(self.delegation().cancel(_json_args(args).get("run_id")), ensure_ascii=False) + data = self.delegation().cancel(_json_args(args).get("run_id")) + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) def _invoke_delegated(self, run, *, review, feedback, coordinator): diff --git a/openkyrozen/agent/executor.py b/openkyrozen/agent/executor.py index fa0b654..2b31d73 100644 --- a/openkyrozen/agent/executor.py +++ b/openkyrozen/agent/executor.py @@ -9,25 +9,26 @@ from openkyrozen.persistence.models import stable_hash, utc_now from openkyrozen.app.config import AgentConfigError, effective_capabilities, load_agent_config from openkyrozen.security.capabilities import issue_capability_token -from openkyrozen.tools.models import CommandResult +from openkyrozen.tools.models import (CommandResult, ToolError, ToolResult, command_tool_result, + render_tool_result, tool_failure) from openkyrozen.security.tool_policy import tool_capability from openkyrozen.agent.types import ExecutionReceipt from openkyrozen.tasks.models import _receipt_args -def _notify_tool_execute(self, action: str, args: Any, result: Any) -> None: - """Emit exactly one isolated plugin hook for every attempted tool call.""" - result_text = str(result) +def _notify_tool_execute(self, action: str, args: Any, result: ToolResult) -> None: + """Emit the typed outcome to observers; display text cannot change status.""" + result = result if isinstance(result, ToolResult) else tool_failure( + "contract_error", "Tool observers require a typed result" + ) try: self._plugin_runtime_for_surface().tool_execute( - action=action, args=args, result=result_text, - success=not self._is_tool_error(result_text), - error=None if not self._is_tool_error(result_text) else result_text, + action=action, args=args, result=render_tool_result(result), + success=result.success, error=result.error.message if result.error else None, user_id=self.memory_bank.user_id, workspace_id=self.memory_bank.workspace_id, session_id=self.memory_bank.session_id, ) except Exception: - # A plugin is an observer and cannot change tool semantics. pass @@ -61,15 +62,13 @@ def _is_state_changing_action(self, action: str, args: str) -> bool: def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, started_at: str, success: bool, result: Any, operation_scope: str, - failure: str | None = None, raw_result: Any = None) -> ExecutionReceipt: + failure: str | None = None, raw_result: Any = None, tool_error: ToolError | None = None) -> ExecutionReceipt: canonical = self._operation_action(action) from openkyrozen.agent.delegation import TOOLS result_text = self._fix_safe_text(result, max(2000, len(str(result))) if canonical in TOOLS else 200000 if canonical in {"read_file", "read_webpage", "git_diff", "git_show", "graph_query"} else 2000, preserve_lines=True) command = raw_result if isinstance(raw_result, CommandResult) else None - if command is not None: - failure = command.failure - effect, effect_verified = self._subagent_tool_evidence(canonical, str(args), result_text) - acceptance, accepted = self._acceptance_for_tool(canonical, str(args), result_text) + effect, effect_verified = self._subagent_tool_evidence(canonical, str(args), result_text, success=success) + acceptance, accepted = self._acceptance_for_tool(canonical, str(args), result_text, success=success) if not accepted and effect_verified: acceptance = effect return ExecutionReceipt( @@ -89,114 +88,92 @@ def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, s exit_code=command.exit_code if command is not None else None, verified_effect=effect if effect_verified else None, acceptance=acceptance if success and (accepted or effect_verified) else None, + tool_error=({"code": tool_error.code, "message": self._fix_safe_text(tool_error.message, 1000), + "retriable": tool_error.retriable, "metadata": tool_error.metadata} if tool_error else None), ) def _run_tool(self, action: str, args: str, *, return_success: bool = False, return_receipt: bool = False, operation_scope: str = "") -> str | tuple[str, bool] | ExecutionReceipt: started_at = utc_now() - - def finish(result: str, success: bool = False, authorized: bool = False, - failure: str | None = None, raw_result: Any = None) -> str | tuple[str, bool] | ExecutionReceipt: - if return_receipt: - return self._make_execution_receipt( - action=action, args=args, authorized=authorized, started_at=started_at, - success=success, result=result, operation_scope=operation_scope, failure=failure, - raw_result=raw_result, - ) - return (result, success) if return_success else result - - # Map aliases action = self.TOOL_ALIASES.get(action, action) - # Handle dict arguments for tools that expect a simple string if isinstance(args, dict): if action in ("run_cmd", "execute_terminal_command"): - cmd = args.get("cmd") or args.get("command") or "" - args = cmd + args = args.get("cmd") or args.get("command") or "" elif action == "write_file": - path = args.get("file_path") or args.get("path") or "" - content = args.get("content") or args.get("text") or "" - args = f"{path}|{content}" + args = f"{args.get('file_path') or args.get('path') or ''}|{args.get('content') or args.get('text') or ''}" else: args = json.dumps(args) - # also try to parse a string that looks like a Python dict literal elif isinstance(args, str) and args.startswith("{"): try: parsed = ast.literal_eval(args) if isinstance(parsed, dict): if action in ("run_cmd", "execute_terminal_command"): - cmd = parsed.get("cmd") or parsed.get("command") or "" - args = cmd + args = parsed.get("cmd") or parsed.get("command") or "" elif action == "write_file": - path = parsed.get("file_path") or parsed.get("path") or "" - content = parsed.get("content") or parsed.get("text") or "" - args = f"{path}|{content}" + args = f"{parsed.get('file_path') or parsed.get('path') or ''}|{parsed.get('content') or parsed.get('text') or ''}" else: args = json.dumps(parsed) except (ValueError, SyntaxError): pass - fn = self.AVAILABLE_TOOLS.get(action) - if not fn: - result = f"Error: unknown tool '{action}'" - self._notify_tool_execute(action, args, result) - return finish(result, failure="unknown_tool") - required_capability = tool_capability(action,self.AVAILABLE_TOOLS) + def finish(outcome: ToolResult, authorized: bool = False, raw_result: Any = None): + text = render_tool_result(outcome) + if return_receipt: + return self._make_execution_receipt( + action=action, args=args, authorized=authorized, started_at=started_at, + success=outcome.success, result=text, operation_scope=operation_scope, + failure=outcome.error.code if outcome.error else None, raw_result=raw_result, + tool_error=outcome.error, + ) + return (text, outcome.success) if return_success else text + if action not in self.AVAILABLE_TOOLS: + outcome = tool_failure("invalid_arguments", f"Error: unknown tool '{action}'") + self._notify_tool_execute(action, args, outcome) + return finish(outcome) + required_capability = tool_capability(action, self.AVAILABLE_TOOLS) try: if required_capability not in effective_capabilities(load_agent_config(self._get_workspace_root())): - result = ( - f"Error: tool '{action}' requires capability '{required_capability}', " - "which is outside the configured agent capability bound" - ) - self._notify_tool_execute(action, args, result) - return finish(result, failure="capability_denied") + outcome = tool_failure("permission_denied", f"Error: tool '{action}' requires capability '{required_capability}', which is outside the configured agent capability bound") + self._notify_tool_execute(action, args, outcome) + return finish(outcome) except AgentConfigError as exc: - result = f"Error: invalid agent configuration: {exc}" - self._notify_tool_execute(action, args, result) - return finish(result, failure="invalid_configuration") + outcome = tool_failure("execution_error", f"Error: invalid agent configuration: {exc}") + self._notify_tool_execute(action, args, outcome) + return finish(outcome) if time.time() >= self._execution_capability_token.expires_at: self._execution_capability_token = issue_capability_token( - self._execution_capability_token.subject, - self._execution_capability_token.capabilities, + self._execution_capability_token.subject, self._execution_capability_token.capabilities, ) if not self._execution_capability_token.allows(required_capability): - result = f"Error: tool '{action}' requires capability '{required_capability}'" - self._notify_tool_execute(action, args, result) - return finish(result, failure="capability_denied") - authorized = ( - self._authorize_tool_action(action, args) - if self._EXECUTION_SURFACE == "tui" - else self._confirm_tool_action(action, str(args)) - ) + outcome = tool_failure("permission_denied", f"Error: tool '{action}' requires capability '{required_capability}'") + self._notify_tool_execute(action, args, outcome) + return finish(outcome) + authorized = (self._authorize_tool_action(action, args) if self._EXECUTION_SURFACE == "tui" + else self._confirm_tool_action(action, str(args))) if not authorized: - result = ( - f"Error: {action} requires confirmation. " - "Approve it interactively or set KYROZEN_APPROVAL_MODE=never for an explicitly automated CLI." - ) - self._notify_tool_execute(action, args, result) - return finish(result, failure="approval_denied") + outcome = tool_failure("approval_denied", f"Error: {action} requires confirmation. Approve it interactively or set KYROZEN_APPROVAL_MODE=never for an explicitly automated CLI.") + self._notify_tool_execute(action, args, outcome) + return finish(outcome) start = time.time() - tool_result: Any = None - failure: str | None = None + raw_result = None try: with self._delegation_tool_access(action, args): - tool_result = self.run_command(args) if action in {"run_cmd", "execute_terminal_command"} else fn(args) - result = str(tool_result) - from openkyrozen.agent.delegation import TOOLS - success = tool_result.success if isinstance(tool_result, CommandResult) else (action in TOOLS or not self._is_tool_error(result)) + if action in {"run_cmd", "execute_terminal_command"}: + raw_result = self.run_command(args) + outcome = command_tool_result(raw_result) + else: + outcome = self.tool_registry._invoke_executor(self.tool_registry.get_spec(action), args) except KeyboardInterrupt: - result = "Tool execution interrupted by user (Ctrl+C)." - success = False - failure = "interrupted" - except Exception as e: - result = f"Error: {e}" - success = False - failure = "execution_error" + outcome = tool_failure("cancelled", "Tool execution interrupted by user (Ctrl+C).") + except Exception as exc: + outcome = tool_failure("execution_error", f"Error: {type(exc).__name__}: {exc}") elapsed = time.time() - start - self._track_tool_performance(action, result, elapsed) - self._notify_tool_execute(action, args, result) - if success and action in {"git_pull", "git_checkout", "git_reset", "git_clone"} and self._project_graph is not None: + text = render_tool_result(outcome) + self._track_tool_performance(action, outcome.success, elapsed) + self._notify_tool_execute(action, args, outcome) + if outcome.success and action in {"git_pull", "git_checkout", "git_reset", "git_clone"} and self._project_graph is not None: self._project_graph.refresh_async() - return finish(result, success, authorized=True, failure=failure, raw_result=tool_result) + return finish(outcome, authorized=True, raw_result=raw_result) def _execute_turn_action(self, action: str, args: Any, *, operation_scope: str, @@ -211,10 +188,11 @@ def _execute_turn_action(self, action: str, args: Any, *, operation_scope: str, "Example: `\"args\": \"python3 process_logs.py\"`.\n" "Do NOT use `\"args\": {\"cmd\": ...}`.\n" ) - self._notify_tool_execute(action, args, result) + outcome = tool_failure("invalid_arguments", result) + self._notify_tool_execute(action, args, outcome) return self._make_execution_receipt( action=canonical, args=args, authorized=False, started_at=started_at, success=False, - result=result, operation_scope=operation_scope, failure="invalid_arguments", + result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, ) args = str(args) operation_id = self._operation_id(operation_scope, canonical, args) @@ -223,16 +201,17 @@ def _execute_turn_action(self, action: str, args: Any, *, operation_scope: str, allowed, reason = self.tasks.mutation_matches_current_task(canonical, args) if not allowed: result = f"Error: action does not match the accepted plan; {reason}." - self._notify_tool_execute(canonical, args, result) + outcome = tool_failure("permission_denied", result) + self._notify_tool_execute(canonical, args, outcome) return self._make_execution_receipt( action=canonical, args=args, authorized=False, started_at=started_at, success=False, result=result, operation_scope=operation_scope, - failure="plan_action_mismatch", + failure=outcome.error.code, tool_error=outcome.error, ) if (canonical != "run_cmd" and self._is_state_changing_action(canonical, args) and operation_id in successful_operations): result = "Error: duplicate successful state-changing action refused for this turn." - self._notify_tool_execute(canonical, args, result) + self._notify_tool_execute(canonical, args, tool_failure("permission_denied", result)) return self._make_execution_receipt( action=canonical, args=args, authorized=True, started_at=started_at, success=False, result=result, operation_scope=operation_scope, failure="duplicate_operation", @@ -274,7 +253,7 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt) -> dict[str, Any]: "receipt_id": receipt.receipt_id, "operation_id": receipt.operation_id, "action": receipt.action, "args": receipt.args, "result": receipt.result, "success": receipt.success, "authorized": receipt.authorized, - "acceptance": effective_acceptance, "failure": receipt.failure, "task_id": task_id, + "acceptance": effective_acceptance, "failure": receipt.failure, "tool_error": receipt.tool_error, "task_id": task_id, } if evidence.get("unmatched_reason"): result["unmatched_reason"] = evidence["unmatched_reason"] diff --git a/openkyrozen/agent/initial_response.py b/openkyrozen/agent/initial_response.py index c65ec80..c7c0b3d 100644 --- a/openkyrozen/agent/initial_response.py +++ b/openkyrozen/agent/initial_response.py @@ -67,9 +67,8 @@ def _initial_turn_response(self, turn): if not unknown_action: break _unknown_retries += 1 - self._notify_tool_execute( - unknown_action, "", f"Error: unknown tool '{unknown_action}'", - ) + from openkyrozen.tools.models import tool_failure + self._notify_tool_execute(unknown_action, "", tool_failure("invalid_arguments", f"Error: unknown tool '{unknown_action}'")) allowed_actions = sorted(self._permitted_tool_names()) msg = ( f"System: Action '{unknown_action}' is not recognized. " diff --git a/openkyrozen/agent/prompts.py b/openkyrozen/agent/prompts.py index b46c3d0..48ce65d 100644 --- a/openkyrozen/agent/prompts.py +++ b/openkyrozen/agent/prompts.py @@ -1,4 +1,5 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import os from typing import Any @@ -54,6 +55,7 @@ def _permitted_tool_names(self, agent_config: dict[str, Any] | None = None) -> s return {name for name in self.AVAILABLE_TOOLS if self.tool_registry.get_spec(name).visibility == 'public' and tool_capability(name,self.AVAILABLE_TOOLS) in capabilities} +@legacy_text_api def _discover_tools(self, args: str) -> str: """Discover permitted tools; args is empty for the catalog or comma-separated exact tool names for full descriptions.""" permitted = self._permitted_tool_names() @@ -62,14 +64,14 @@ def _discover_tools(self, args: str) -> str: groups: dict[str, list[str]] = {} for name in sorted(permitted): groups.setdefault(tool_capability(name,self.AVAILABLE_TOOLS), []).append(name) - return "\n".join(f"{capability}: {', '.join(group)}" for capability, group in sorted(groups.items())) + return tool_success(str("\n".join(f"{capability}: {', '.join(group)}" for capability, group in sorted(groups.items())))) if any(name not in permitted for name in names): - return "Error: unknown or unavailable tool requested" + return tool_failure('execution_error', "Error: unknown or unavailable tool requested", retriable=False) self.execution_context.discovered_tools |= frozenset(names) - return "\n".join( + return tool_success(str("\n".join( f"- {name}: {self.tool_registry.get_spec(name).description}" for name in names - ) + ))) def _agent_prompt_tools_list(self, agent_config: dict[str, Any]) -> str: @@ -160,7 +162,7 @@ def _system_prompt(self, tools_list: str, agent_config: dict[str, Any] | None = "Dynamic tools are enabled only when the active surface grants the `dynamic` capability and the approval policy allows registration. " "When a missing pure helper is genuinely needed, you may output exactly one DefineTool block; never include imports, filesystem/process/network access, secrets, permission changes, or capability grants. " "The runtime validates and registers it, refreshes the tool inventory, and then you may call it by its exact name:\n" - "DefineTool:\n```python\ndef tool_name(args: str) -> str:\n return args.strip()\n```\n" + "DefineTool:\n```python\ndef tool_name(args: str) -> ToolResult:\n return ToolResult(data=args.strip(), text=args.strip())\n```\n" ) if self.ALLOW_DYNAMIC_TOOLS and interaction_mode == "agent" else ( "## Dynamic tools\n" "Dynamic tool creation is disabled for this execution surface. Do not emit DefineTool blocks.\n" diff --git a/openkyrozen/agent/subagent_runtime.py b/openkyrozen/agent/subagent_runtime.py index ddaa36b..b4c4e62 100644 --- a/openkyrozen/agent/subagent_runtime.py +++ b/openkyrozen/agent/subagent_runtime.py @@ -8,6 +8,7 @@ from typing import Any from openkyrozen.agent.subagents import AgentProfile from openkyrozen.persistence.models import utc_now +from openkyrozen.tools.models import ToolError, render_tool_result, tool_failure from openkyrozen.security.capabilities import issue_capability_token from openkyrozen.security.tool_policy import resolve_capabilities @@ -27,9 +28,9 @@ def _subagent_action_arguments(self, action: str, args: Any) -> Any: return args -def _subagent_tool_evidence(self, action: str, args: str, result: str) -> tuple[str | None, bool]: +def _subagent_tool_evidence(self, action: str, args: str, result: str, *, success: bool) -> tuple[str | None, bool]: """Verify the observable effect of the small set of artifact-producing tools.""" - if action != "write_file" or self._is_tool_error(result): + if action != "write_file" or not success: return None, False raw_path = str(args).split("|", 1)[0].strip() if not raw_path: @@ -61,15 +62,16 @@ def _run_subagent_tool(self, profile: AgentProfile, action: str, args: Any, tool rejection = "Error: Action args must be a plain string or supported object." if rejection: authorized = canonical in tools + outcome = tool_failure("invalid_arguments" if authorized else "permission_denied", rejection) receipt = self._make_execution_receipt( action=canonical, args=args, authorized=authorized, started_at=utc_now(), success=False, result=rejection, operation_scope=f"subagent:{profile.name}", - failure="invalid_arguments" if authorized else "capability_denied", + failure=outcome.error.code, tool_error=outcome.error, ) self.memory_bank.store.append_event("execution.receipt", receipt.as_dict(), user_id=self.memory_bank.user_id, workspace_id=self.memory_bank.workspace_id, session_id=self.memory_bank.session_id) - self._notify_tool_execute(canonical, args, rejection) + self._notify_tool_execute(canonical, args, tool_failure("invalid_arguments" if authorized else "permission_denied", rejection)) return {"receipt_id": receipt_id, "action": canonical, "args": str(args)[:500], "result": rejection, "success": False, "authorized": authorized} normalized_args = self._subagent_action_arguments(canonical, args) @@ -88,15 +90,22 @@ def _run_subagent_tool(self, profile: AgentProfile, action: str, args: Any, tool operation_scope=f"subagent:{profile.name}", approve=self._approval_callback.get()) result = receipt.result except Exception as exc: - result = f"Error: {type(exc).__name__}: {exc}" + outcome = tool_failure("execution_error", f"Error: {type(exc).__name__}") + result = render_tool_result(outcome) + receipt = self._make_execution_receipt( + action=canonical, args=normalized_args, authorized=True, started_at=utc_now(), + success=False, result=result, operation_scope=f"subagent:{profile.name}", + failure=outcome.error.code, tool_error=outcome.error, + ) finally: self._execution_capability_token = previous_token if receipt and self.execution_context.coordinator: self.execution_context.coordinator.record_receipt(self.execution_context.child_run_id, self._active_usage_run_id.get(), receipt.as_dict()) - acceptance, accepted = self._acceptance_for_tool(canonical, normalized_args, result) - effect, effect_verified = self._subagent_tool_evidence(canonical, normalized_args, result) - success = receipt is not None and receipt.success and (canonical != "write_file" or effect_verified) + execution_success = bool(receipt and receipt.success) + acceptance, accepted = self._acceptance_for_tool(canonical, normalized_args, result, success=execution_success) + effect, effect_verified = self._subagent_tool_evidence(canonical, normalized_args, result, success=execution_success) + success = execution_success return { **(receipt.as_dict() if receipt else {}), "receipt_id": receipt.receipt_id if receipt else receipt_id, "action": canonical, "args": normalized_args[:1000], @@ -214,7 +223,8 @@ def call(): rejected_action = unknown or ("unsupported_protocol" if unsupported_protocol else "malformed") self._notify_tool_execute( str(rejected_action), "", - "Error: malformed or unknown Action rejected; no tool was executed.", + tool_failure( + "invalid_arguments", "Error: malformed or unknown Action rejected; no tool was executed."), ) tool_records.append({ "receipt_id": f"subagent_receipt_{uuid.uuid4().hex}", diff --git a/openkyrozen/agent/types.py b/openkyrozen/agent/types.py index bf58699..38b3549 100644 --- a/openkyrozen/agent/types.py +++ b/openkyrozen/agent/types.py @@ -91,6 +91,7 @@ class ExecutionReceipt: verified_effect: str | None = None acceptance: str | None = None args_sha256: str | None = None + tool_error: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: return { @@ -108,6 +109,7 @@ def as_dict(self) -> dict[str, Any]: "exit_code": self.exit_code, "verified_effect": self.verified_effect, "acceptance": self.acceptance, + "tool_error": self.tool_error, } diff --git a/openkyrozen/interfaces/mcp/server.py b/openkyrozen/interfaces/mcp/server.py index 452588f..441fd64 100644 --- a/openkyrozen/interfaces/mcp/server.py +++ b/openkyrozen/interfaces/mcp/server.py @@ -4,6 +4,7 @@ import asyncio from typing import Any from openkyrozen.tools import tool_capability +from openkyrozen.tools.models import tool_failure from openkyrozen.security.capabilities import issue_capability_token from fastapi import FastAPI, Request, HTTPException, Depends @@ -88,15 +89,15 @@ async def mcp_endpoint(self, request: Request): tool_name = params.get("name", "") tool_args = params.get("arguments", "") if not isinstance(tool_name, str) or not tool_name: - service._agent._notify_tool_execute(str(tool_name), tool_args, "Error: tool name is required") + service._agent._notify_tool_execute(str(tool_name), tool_args, tool_failure("invalid_arguments", "Error: tool name is required")) return service._mcp_error(request_id, -32602, "Invalid params: tool name is required") fn = service._agent.AVAILABLE_TOOLS.get(tool_name) if fn is None: - service._agent._notify_tool_execute(tool_name, tool_args, f"Error: Tool '{tool_name}' not found") + service._agent._notify_tool_execute(tool_name, tool_args, tool_failure("invalid_arguments", f"Error: Tool '{tool_name}' not found")) return service._mcp_error(request_id, -32601, f"Tool '{tool_name}' not found") if tool_name not in service._allowed_server_tools("mcp"): service._agent._notify_tool_execute( - tool_name, tool_args, f"Error: Tool '{tool_name}' is not authorized", + tool_name, tool_args, tool_failure("permission_denied", f"Error: Tool '{tool_name}' is not authorized"), ) return service._mcp_error(request_id, -32001, ( f"Tool requires '{tool_capability(tool_name,service._agent.AVAILABLE_TOOLS)}' capability; " @@ -105,7 +106,7 @@ async def mcp_endpoint(self, request: Request): try: string_args = service._mcp_string_arguments(tool_name, tool_args) except (TypeError, ValueError) as e: - service._agent._notify_tool_execute(tool_name, tool_args, f"Error: invalid params: {e}") + service._agent._notify_tool_execute(tool_name, tool_args, tool_failure("invalid_arguments", f"Error: invalid params: {e}")) return service._mcp_error(request_id, -32602, f"Invalid params: {e}") try: def execute(): diff --git a/openkyrozen/learning/evolution.py b/openkyrozen/learning/evolution.py index 1f1d911..f533b00 100644 --- a/openkyrozen/learning/evolution.py +++ b/openkyrozen/learning/evolution.py @@ -7,12 +7,12 @@ from openkyrozen.tasks.engine import canonical_status -def _acceptance_for_tool(self, action: str, args: str, result: str) -> tuple[str | None, bool]: +def _acceptance_for_tool(self, action: str, args: str, result: str, *, success: bool) -> tuple[str | None, bool]: """Recognise hard coding checks; ordinary successful tools are not acceptance evidence.""" if action not in {"run_cmd", "execute_terminal_command"} or not self._ACCEPTANCE_COMMAND_RE.search(str(args)): return None, False lowered = str(result).lower() - failed = self._is_tool_error(result) or any(marker in lowered for marker in ( + failed = not success or any(marker in lowered for marker in ( "failed (failures=", "failed (errors=", "tests failed", "error: test failed", "not ok", )) return str(args)[:300], not failed diff --git a/openkyrozen/learning/failures.py b/openkyrozen/learning/failures.py index 318619d..1619041 100644 --- a/openkyrozen/learning/failures.py +++ b/openkyrozen/learning/failures.py @@ -31,11 +31,10 @@ def _emit_stream_event(self, event: dict[str, Any]) -> None: pass -def _track_tool_performance(self, action: str, result: str, elapsed: float) -> None: +def _track_tool_performance(self, action: str, success: bool, elapsed: float) -> None: stats = self._tool_stats.setdefault(action, {"calls":0,"successes":0,"total_time":0.0}) stats["calls"] += 1 stats["total_time"] += elapsed - success = not self._is_tool_error(result) if success: stats["successes"] += 1 self._record_learning_event("learning.tool_performance", { diff --git a/openkyrozen/memory/retrieval.py b/openkyrozen/memory/retrieval.py index d6712d4..1e192bd 100644 --- a/openkyrozen/memory/retrieval.py +++ b/openkyrozen/memory/retrieval.py @@ -1,8 +1,10 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success from typing import Any +@legacy_text_api def _check_stored_data(self, args: str) -> str: """Return a categorized summary of stored memories. Usage: empty args for overview, or a category prefix like 'FACT', 'SKILL', 'STRATEGY' @@ -83,7 +85,7 @@ def _check_stored_data(self, args: str) -> str: lines.append(f"\n'{filter_arg}' entries are excluded from the learning overview (source code or raw conversations).") else: lines.append(f"\nUnknown category '{filter_arg}'. Known learning categories: {', '.join(k.rstrip(':') for k in _LEARNING_CATEGORIES)}") - return "\n".join(lines) + return tool_success(str("\n".join(lines))) # ---- Overview ---- lines.append("\n## Learning Categories Overview") @@ -119,9 +121,9 @@ def _check_stored_data(self, args: str) -> str: lines.append("\n(No self‑learning artifacts found in the recent window. They may be deeper in storage — try `search_memory`.)") lines.append(f"\nTip: use `check_stored_data FACT` to drill into a category, or `search_memory ` for semantic search.") - return "\n".join(lines) + return tool_success(str("\n".join(lines))) except Exception as e: - return f"Error reading memory: {e}" + return tool_failure('execution_error', f"Error reading memory: {e}", retriable=False) def _has_cjk(self, text: str) -> bool: @@ -167,12 +169,13 @@ def _cjk_approximate_words(self, text: str) -> int: return max(count, 1) +@legacy_text_api def _search_memory(self, args: str) -> str: """Search stored memories for facts relevant to the query. Args: "query" """ try: query = args.strip() if args else "" if not query: - return "Search Error: provide a query." + return tool_failure('invalid_arguments', "Search Error: provide a query.", retriable=False) # First attempt: semantic search via ChromaDB/memory recall results = self.memory_bank.recall(query, n_results=5) if not results: @@ -198,17 +201,17 @@ def _search_memory(self, args: str) -> str: results = matched[:5] else: # No results even after fallback - return "No relevant memories found." + return tool_success(str("No relevant memories found.")) lines = [f"Relevant memories ({len(results)}):"] for i, doc in enumerate(results): snippet = self._safe_fstring(doc[:500].replace("\n", " ")) lines.append(f"\n--- Result {i+1} ---\n{snippet}") ret = "\n".join(lines) if ret.strip() == f"Relevant memories ({len(results)}):": - return "No relevant memories found for that query." - return ret + return tool_success(str("No relevant memories found for that query.")) + return tool_success(str(ret)) except Exception as e: - return f"Error searching memory: {e}" + return tool_failure('execution_error', f"Error searching memory: {e}", retriable=False) def _build_memory_context(self, query: str, n: int = 3, context: dict[str, Any] | None = None) -> str: diff --git a/openkyrozen/tools/__init__.py b/openkyrozen/tools/__init__.py index 8a4d7a9..a353429 100644 --- a/openkyrozen/tools/__init__.py +++ b/openkyrozen/tools/__init__.py @@ -1,5 +1,5 @@ """Tool contracts and adapter construction.""" -from .models import CommandResult, ToolError, ToolResult, render_tool_result +from .models import CommandResult, ToolError, ToolResult, command_tool_result, invoke_tool_result, legacy_text_api, render_tool_result, tool_failure, tool_json_result, tool_success from .adapters import ToolAdapters from openkyrozen.security.tool_policy import allowed_tool_names, resolve_capabilities, tool_capability diff --git a/openkyrozen/tools/browser.py b/openkyrozen/tools/browser.py index 79db0ab..b798266 100644 --- a/openkyrozen/tools/browser.py +++ b/openkyrozen/tools/browser.py @@ -1,30 +1,35 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, invoke_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE +@legacy_text_api def browser_open(self, args: str) -> str: """Open a URL in an isolated headless browser profile. Args: URL.""" - return self._BROWSER.open(args.strip()) + return invoke_tool_result(self._BROWSER.open, args.strip()) +@legacy_text_api def browser_snapshot(self, args: str) -> str: """Read the current page text. Args: browser session ID.""" - return self._BROWSER.snapshot(args.strip()) + return invoke_tool_result(self._BROWSER.snapshot, args.strip()) +@legacy_text_api def browser_click(self, args: str) -> str: """Click a selector. Args format: session_id|CSS selector.""" - return self._BROWSER.click(args) + return invoke_tool_result(self._BROWSER.click, args) +@legacy_text_api def browser_type(self, args: str) -> str: """Fill a selector. Args format: session_id|CSS selector|text.""" - return self._BROWSER.type_text(args) + return invoke_tool_result(self._BROWSER.type_text, args) +@legacy_text_api def browser_close(self, args: str) -> str: """Close an isolated browser session. Args: browser session ID.""" - return self._BROWSER.close(args.strip()) + return invoke_tool_result(self._BROWSER.close, args.strip()) diff --git a/openkyrozen/tools/browser_manager.py b/openkyrozen/tools/browser_manager.py index ea016a6..ebfa485 100644 --- a/openkyrozen/tools/browser_manager.py +++ b/openkyrozen/tools/browser_manager.py @@ -1,6 +1,7 @@ """Optional isolated browser control with per-agent profiles.""" from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, invoke_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import ipaddress import os @@ -44,14 +45,15 @@ def __init__(self, root: str | os.PathLike[str] | None = None): self.root.mkdir(parents=True, exist_ok=True) self._sessions: dict[str, tuple[object, object, object]] = {} + @legacy_text_api def open(self, url: str) -> str: valid, reason = validate_browser_url(url) if not valid: - return f"Error: {reason}" + return tool_failure('permission_denied' if 'private' in reason.lower() or 'credentials' in reason.lower() else 'invalid_arguments', f"Error: {reason}", retriable=False) try: from playwright.sync_api import sync_playwright except ImportError: - return "Error: browser support requires playwright; install it with `pip install playwright && playwright install chromium`." + return tool_failure('dependency_unavailable', "Error: browser support requires playwright; install it with `pip install playwright && playwright install chromium`.", retriable=False) session_id = f"browser_{uuid.uuid4().hex[:12]}" try: playwright = sync_playwright().start() @@ -61,63 +63,67 @@ def open(self, url: str) -> str: page = context.pages[0] if context.pages else context.new_page() page.goto(url, wait_until="domcontentloaded", timeout=30_000) self._sessions[session_id] = (playwright, context, page) - return f"Browser session {session_id} opened {page.url}\nTitle: {page.title()}" + return tool_success(str(f"Browser session {session_id} opened {page.url}\nTitle: {page.title()}")) except Exception as exc: try: playwright.stop() except Exception: pass - return f"Error opening browser: {exc}" + return tool_failure('execution_error', f"Error opening browser: {exc}", retriable=False) + @legacy_text_api def snapshot(self, session_id: str) -> str: session = self._sessions.get(session_id.strip()) if not session: - return "Error: browser session not found" + return tool_failure('not_found', "Error: browser session not found", retriable=False) _, _, page = session try: return ( - f"URL: {page.url}\nTitle: {page.title()}\n\n" - f"{page.locator('body').inner_text(timeout=BROWSER_SNAPSHOT_TIMEOUT_MS)[:12000]}" + tool_success(str(f"URL: {page.url}\nTitle: {page.title()}\n\n" + f"{page.locator('body').inner_text(timeout=BROWSER_SNAPSHOT_TIMEOUT_MS)[:12000]}")) ) except Exception as exc: - return f"Error reading browser page: {exc}" + return tool_failure('execution_error', f"Error reading browser page: {exc}", retriable=False) + @legacy_text_api def click(self, args: str) -> str: parts = args.split("|", 1) if len(parts) != 2: - return "Error: browser_click args format is session_id|selector" + return tool_failure('invalid_arguments', "Error: browser_click args format is session_id|selector", retriable=False) session = self._sessions.get(parts[0].strip()) if not session: - return "Error: browser session not found" + return tool_failure('not_found', "Error: browser session not found", retriable=False) try: session[2].locator(parts[1].strip()).click(timeout=10_000) - return self.snapshot(parts[0]) + return invoke_tool_result(self.snapshot, parts[0]) except Exception as exc: - return f"Error clicking browser element: {exc}" + return tool_failure('execution_error', f"Error clicking browser element: {exc}", retriable=False) + @legacy_text_api def type_text(self, args: str) -> str: parts = args.split("|", 2) if len(parts) != 3: - return "Error: browser_type args format is session_id|selector|text" + return tool_failure('invalid_arguments', "Error: browser_type args format is session_id|selector|text", retriable=False) session = self._sessions.get(parts[0].strip()) if not session: - return "Error: browser session not found" + return tool_failure('not_found', "Error: browser session not found", retriable=False) try: session[2].locator(parts[1].strip()).fill(parts[2]) - return "Text entered." + return tool_success(str("Text entered.")) except Exception as exc: - return f"Error typing in browser: {exc}" + return tool_failure('execution_error', f"Error typing in browser: {exc}", retriable=False) + @legacy_text_api def close(self, session_id: str) -> str: session = self._sessions.pop(session_id.strip(), None) if not session: - return "Error: browser session not found" + return tool_failure('not_found', "Error: browser session not found", retriable=False) try: session[1].close() session[0].stop() - return f"Browser session {session_id} closed." + return tool_success(str(f"Browser session {session_id} closed.")) except Exception as exc: - return f"Error closing browser: {exc}" + return tool_failure('execution_error', f"Error closing browser: {exc}", retriable=False) def close_all(self) -> None: for session_id in list(self._sessions): diff --git a/openkyrozen/tools/calculation.py b/openkyrozen/tools/calculation.py index 9eb1c26..a030f7f 100644 --- a/openkyrozen/tools/calculation.py +++ b/openkyrozen/tools/calculation.py @@ -1,5 +1,6 @@ """Bounded arithmetic checks, with no code, filesystem or process execution.""" from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import ast from decimal import Decimal, localcontext @@ -49,6 +50,7 @@ def _value(node): raise ValueError("Only literals, arithmetic, comparisons and listed pure functions are allowed") +@legacy_text_api def calculate(self, args: str) -> str: """Check a bounded arithmetic expression, e.g. sum([Decimal('1.00'), Decimal('2.00')]); no workspace code runs.""" try: @@ -60,6 +62,6 @@ def calculate(self, args: str) -> str: with localcontext() as context: context.prec, context.Emax, context.Emin = 28, 256, -256 value = _value(tree.body) - return f"{type(value).__name__}: {value!r} (arithmetic check only; Decimal precision 28)" + return tool_success(str(f"{type(value).__name__}: {value!r} (arithmetic check only; Decimal precision 28)")) except Exception as exc: - return f"Error: arithmetic check {type(exc).__name__}: {exc}" + return tool_failure('invalid_arguments', f"Error: arithmetic check {type(exc).__name__}: {exc}", retriable=False) diff --git a/openkyrozen/tools/dynamic.py b/openkyrozen/tools/dynamic.py index feffde5..5a319a5 100644 --- a/openkyrozen/tools/dynamic.py +++ b/openkyrozen/tools/dynamic.py @@ -63,8 +63,9 @@ def _register_tool(self, name: str, code: str, description: str = "") -> bool: # Compile the tool function in a restricted global namespace. try: + from openkyrozen.tools.models import ToolError, ToolResult local_ns: dict[str, Any] = {} - exec(code, {"__builtins__": SAFE_BUILTINS}, local_ns) + exec(code, {"__builtins__": SAFE_BUILTINS, "ToolError": ToolError, "ToolResult": ToolResult}, local_ns) fn = local_ns.get(name) if fn is None or not callable(fn): return self._reject_dynamic_tool(name, "source did not create the requested callable") @@ -97,7 +98,7 @@ def _attempt_define_tool(self, text: str) -> bool: Format: DefineTool: ```python - def tool_name(args: str) -> str: + def tool_name(args: str) -> ToolResult: '''Description''' ... ``` diff --git a/openkyrozen/tools/filesystem.py b/openkyrozen/tools/filesystem.py index 3db3558..9e789ad 100644 --- a/openkyrozen/tools/filesystem.py +++ b/openkyrozen/tools/filesystem.py @@ -1,4 +1,5 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import os import glob @@ -14,11 +15,11 @@ import time from pathlib import Path -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE _SEARCH_EXCLUDED_DIRECTORIES = (".git", "venv", ".venv", "node_modules", "__pycache__") +@legacy_text_api def find_files(self, args: str) -> str: """ Find files matching a pattern. Args format: "pattern" or "pattern|directory". @@ -32,7 +33,7 @@ def find_files(self, args: str) -> str: directory = parts[1].strip() directory_path = self._resolve_workspace_path(directory) if os.path.isabs(pattern): - return "Error: absolute patterns are not allowed" + return tool_failure('invalid_arguments', "Error: absolute patterns are not allowed", retriable=False) full_path = os.path.join(str(directory_path), pattern) matches = [] for match in glob.glob(full_path, recursive=True): @@ -42,12 +43,13 @@ def find_files(self, args: str) -> str: continue matches.append(match) if not matches: - return "No files found." - return "\n".join(matches) + return tool_success(str("No files found.")) + return tool_success(str("\n".join(matches))) except Exception as e: - return f"Error finding files: {e}" + return tool_failure('execution_error', f"Error finding files: {e}", retriable=False) +@legacy_text_api def list_dir(self, args: str) -> str: """ List contents of a directory. Args format: "path" (default "."). @@ -57,11 +59,12 @@ def list_dir(self, args: str) -> str: dir_path = args.strip() or "." abs_path = self._resolve_workspace_path(dir_path) entries = os.listdir(abs_path) - return "\n".join(sorted(entries)) + return tool_success(str("\n".join(sorted(entries)))) except Exception as e: - return f"Error listing directory: {e}" + return tool_failure('execution_error', f"Error listing directory: {e}", retriable=False) +@legacy_text_api def list_tree(self, args: str) -> str: """ Recursively list the directory tree of the given path. Args format: "path" (default "."). @@ -73,7 +76,7 @@ def list_tree(self, args: str) -> str: path = args.strip() or "." root = self._resolve_workspace_path(path) if not root.is_dir(): - return f"Error: '{path}' is not a directory" + return tool_failure('not_found', f"Error: '{path}' is not a directory", retriable=False) lines = [f"{root.name}/"] for dirpath, dirnames, filenames in os.walk(root): # skip hidden dirs @@ -84,11 +87,12 @@ def list_tree(self, args: str) -> str: lines.append(f"{prefix}{d}/") for f in sorted(filenames): lines.append(f"{prefix}{f}") - return "\n".join(lines) + return tool_success(str("\n".join(lines))) except Exception as e: - return f"Error listing tree: {e}" + return tool_failure('execution_error', f"Error listing tree: {e}", retriable=False) +@legacy_text_api def write_file(self, args: str) -> str: """ Write content to a file. Args format: "path|content". @@ -97,17 +101,18 @@ def write_file(self, args: str) -> str: try: parts = args.split("|", 1) if len(parts) < 2: - return "Error: write_file requires args in format path|content" + return tool_failure('invalid_arguments', "Error: write_file requires args in format path|content", retriable=False) raw_path, content = parts[0].strip(), parts[1] abs_path = self._resolve_workspace_path(raw_path) abs_path.parent.mkdir(parents=True, exist_ok=True) with open(abs_path, "w", encoding="utf-8") as f: f.write(content) - return f"Wrote {len(content)} characters to {abs_path}" + return tool_success(str(f"Wrote {len(content)} characters to {abs_path}")) except Exception as e: - return f"Error writing file: {e}" + return tool_failure('execution_error', f"Error writing file: {e}", retriable=False) +@legacy_text_api def read_file(self, args: str) -> str: """ Read content from a file. Args format: "path". @@ -117,14 +122,14 @@ def read_file(self, args: str) -> str: if args.lstrip().startswith("{"): request = json.loads(args) if not isinstance(request, dict) or not isinstance(request.get("path"), str): - return "Error: structured read requires a path" + return tool_failure('invalid_arguments', "Error: structured read requires a path", retriable=False) abs_path = self._resolve_workspace_path(request["path"]) start = request.get("start_line", 1) limit = request.get("max_lines", 200) max_chars = request.get("max_chars", 20_000) if (any(not isinstance(item, int) or isinstance(item, bool) for item in (start, limit, max_chars)) or start < 1 or limit < 1 or max_chars < 1): - return "Error: start_line, max_lines, and max_chars must be positive integers" + return tool_failure('invalid_arguments', "Error: start_line, max_lines, and max_chars must be positive integers", retriable=False) limit, max_chars = min(limit, 200), min(max_chars, 20_000) digest = hashlib.sha256() decoder = codecs.getincrementaldecoder("utf-8")() @@ -164,24 +169,26 @@ def keep_piece(piece: str) -> None: excerpt = "".join(excerpt_parts)[:max_chars] clipped = excerpt_size > max_chars relative = abs_path.relative_to(self._WORKSPACE_ROOT).as_posix() - return json.dumps({ + data = { "path": relative, "sha256": digest.hexdigest(), "start_line": start, "end_line": min(total, start + limit - 1), "total_lines": total, "truncated": clipped or start + limit - 1 < total, "content": excerpt, - }) + } + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) raw_path = args.strip() if not raw_path: - return "Error: read_file requires a path" + return tool_failure('invalid_arguments', "Error: read_file requires a path", retriable=False) abs_path = self._resolve_workspace_path(raw_path) with open(abs_path, "r", encoding="utf-8") as f: - return f.read() + return tool_success(str(f.read())) except FileNotFoundError: - return f"Error: file not found: {args.strip()}" + return tool_failure('not_found', f"Error: file not found: {args.strip()}", retriable=False) except Exception as e: - return f"Error reading file: {e}" + return tool_failure('execution_error', f"Error reading file: {e}", retriable=False) +@legacy_text_api def edit_file(self, args: str) -> str: """Replace one exact snippet only when the file still matches its read hash.""" temporary = None @@ -189,18 +196,18 @@ def edit_file(self, args: str) -> str: request = json.loads(args) if not isinstance(request, dict) or not all(isinstance(request.get(key), str) for key in ("path", "old_text", "new_text", "expected_sha256")): - return "Error: edit_file requires path, old_text, new_text, and expected_sha256 strings" + return tool_failure('invalid_arguments', "Error: edit_file requires path, old_text, new_text, and expected_sha256 strings", retriable=False) old, new = request["old_text"], request["new_text"] if not old: - return "Error: old_text must not be empty" + return tool_failure('invalid_arguments', "Error: old_text must not be empty", retriable=False) path = self._resolve_workspace_path(request["path"]) original = path.read_bytes() digest = hashlib.sha256(original).hexdigest() if digest != request["expected_sha256"]: - return "Error: file is stale; read it again before editing" + return tool_failure('stale_file', "Error: file is stale; read it again before editing", retriable=False) content = original.decode("utf-8") if content.count(old) != 1: - return "Error: old_text must match exactly once" + return tool_failure('stale_file', "Error: old_text must match exactly once", retriable=False) updated = content.replace(old, new, 1).encode("utf-8") descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) with os.fdopen(descriptor, "wb") as output: @@ -209,12 +216,12 @@ def edit_file(self, args: str) -> str: os.fsync(output.fileno()) os.chmod(temporary, stat.S_IMODE(path.stat().st_mode)) if hashlib.sha256(path.read_bytes()).hexdigest() != request["expected_sha256"]: - return "Error: file changed during edit; read it again before editing" + return tool_failure('stale_file', "Error: file changed during edit; read it again before editing", retriable=False) os.replace(temporary, path) temporary = None - return f"Updated {path.relative_to(self._WORKSPACE_ROOT).as_posix()}" + return tool_success(str(f"Updated {path.relative_to(self._WORKSPACE_ROOT).as_posix()}")) except Exception as exc: - return f"Error editing file: {exc}" + return tool_failure('execution_error', f"Error editing file: {exc}", retriable=False) finally: if temporary: try: @@ -223,19 +230,20 @@ def edit_file(self, args: str) -> str: pass +@legacy_text_api def search_files(self, args: str) -> str: """Search literal text with bounded, relative file-and-line results.""" try: request = json.loads(args) if not isinstance(request, dict) or not isinstance(request.get("query"), str) or not request["query"]: - return "Error: search_files requires a non-empty literal query" + return tool_failure('invalid_arguments', "Error: search_files requires a non-empty literal query", retriable=False) scope = self._resolve_workspace_path(request.get("path", ".")) pattern = request.get("glob", "*") if not isinstance(pattern, str) or Path(pattern).is_absolute() or ".." in Path(pattern).parts: - return "Error: glob must be a workspace-relative pattern" + return tool_failure('invalid_arguments', "Error: glob must be a workspace-relative pattern", retriable=False) limit = request.get("limit", 100) if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1: - return "Error: limit must be a positive integer" + return tool_failure('invalid_arguments', "Error: limit must be a positive integer", retriable=False) limit = min(limit, 100) query = request["query"] rg = shutil.which("rg") @@ -295,15 +303,15 @@ def kill_search() -> None: pass process.stdout.close() if output: - return "\n".join(output) - return "Search timed out." if timed_out.is_set() else "No matches." + return tool_success(str("\n".join(output))) + return (tool_failure('timeout', "Search timed out.", retriable=False) if timed_out.is_set() else tool_success(str("No matches."))) output, size = [], 0 deadline = time.monotonic() + 5 files = [scope] if scope.is_file() else scope.rglob("*") for path in files: if time.monotonic() >= deadline: - return "\n".join(output) if output else "Search timed out." + return (tool_success(str("\n".join(output))) if output else tool_failure('timeout', "Search timed out.", retriable=False)) try: path = self._resolve_workspace_path(str(path)) except ValueError: @@ -334,14 +342,14 @@ def record_line() -> bool: with path.open("rb") as source: while chunk := source.read(64 * 1024): if time.monotonic() >= deadline: - return "\n".join(output) if output else "Search timed out." + return (tool_success(str("\n".join(output))) if output else tool_failure('timeout', "Search timed out.", retriable=False)) parts = chunk.split(b"\n") for piece in parts[:-1]: probe = overlap + piece found = found or needle in probe prefix = (prefix + piece)[:401] if record_line(): - return "\n".join(output) + return tool_success(str("\n".join(output))) number += 1 overlap, prefix, found = b"", b"", False piece = parts[-1] @@ -351,9 +359,9 @@ def record_line() -> bool: overlap = probe[-(len(needle) - 1):] if len(needle) > 1 else b"" if prefix or found: if record_line(): - return "\n".join(output) + return tool_success(str("\n".join(output))) except OSError: continue - return "\n".join(output) if output else "No matches." + return (tool_success(str("\n".join(output))) if output else tool_success(str("No matches."))) except Exception as exc: - return f"Error searching files: {exc}" + return tool_failure('execution_error', f"Error searching files: {exc}", retriable=False) diff --git a/openkyrozen/tools/git.py b/openkyrozen/tools/git.py index 4cae1b6..4ee7dd5 100644 --- a/openkyrozen/tools/git.py +++ b/openkyrozen/tools/git.py @@ -1,11 +1,11 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import os import shlex import subprocess from pathlib import Path -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE def _git_working_directory(self, path: str = ".") -> str: @@ -16,6 +16,7 @@ def _git_working_directory(self, path: str = ".") -> str: return str(candidate.resolve()) +@legacy_text_api def git_clone(self, args: str) -> str: """ Clone a git repository. Args format: "url" or "url|destination". @@ -35,12 +36,13 @@ def git_clone(self, args: str) -> str: cmd, capture_output=True, text=True, timeout=60, cwd=str(self._WORKSPACE_ROOT), ) if result.returncode != 0: - return f"Git clone failed:\n{result.stderr}" - return f"Repository cloned successfully:\n{result.stdout}" + return tool_failure('command_failed', f"Git clone failed:\n{result.stderr}", retriable=False) + return tool_success(str(f"Repository cloned successfully:\n{result.stdout}")) except Exception as e: - return f"Error during git clone: {e}" + return tool_failure('execution_error', f"Error during git clone: {e}", retriable=False) +@legacy_text_api def git_diff(self, args: str) -> str: """ Show git diff (unstaged, staged, or between commits). @@ -56,13 +58,14 @@ def git_diff(self, args: str) -> str: cmd.append(part) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git diff failed:\n{result.stderr}" + return tool_failure('command_failed', f"Git diff failed:\n{result.stderr}", retriable=False) out = result.stdout.strip() - return out if out else "(no changes)" + return (tool_success(str(out)) if out else tool_success(str("(no changes)"))) except Exception as e: - return f"Error running git diff: {e}" + return tool_failure('execution_error', f"Error running git diff: {e}", retriable=False) +@legacy_text_api def git_log(self, args: str) -> str: """ Show git commit log. Args format: "-5" or "--since='2 days ago' --author='Name'" @@ -80,13 +83,14 @@ def git_log(self, args: str) -> str: cmd.extend(extra) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git log failed:\n{result.stderr}" + return tool_failure('command_failed', f"Git log failed:\n{result.stderr}", retriable=False) out = result.stdout.strip() - return out if out else "(no commits)" + return (tool_success(str(out)) if out else tool_success(str("(no commits)"))) except Exception as e: - return f"Error running git log: {e}" + return tool_failure('execution_error', f"Error running git log: {e}", retriable=False) +@legacy_text_api def git_branch(self, args: str) -> str: """ List or manage git branches. Args format: "" (list all), "branch_name" (create), @@ -104,7 +108,7 @@ def git_branch(self, args: str) -> str: cmd.extend(extra) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git branch failed:\n{result.stderr}" + return tool_failure('command_failed', f"Git branch failed:\n{result.stderr}", retriable=False) out = result.stdout.strip() mutation_flags = {"-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy", "-f", "--force"} mutation = bool(extra and (extra[0] in mutation_flags or not extra[0].startswith("-"))) @@ -112,18 +116,19 @@ def git_branch(self, args: str) -> str: targets = [item for item in extra if item != "--" and not item.startswith("-")] if extra[0] in {"-d", "-D", "--delete"}: names = ", ".join(repr(item) for item in targets) or "the requested branch" - return f"Deleted branch {names}." + return tool_success(str(f"Deleted branch {names}.")) if extra[0] in {"-m", "-M", "--move"} and len(targets) >= 2: - return f"Renamed branch {targets[-2]!r} to {targets[-1]!r}." + return tool_success(str(f"Renamed branch {targets[-2]!r} to {targets[-1]!r}.")) if extra[0] in {"-c", "-C", "--copy"} and len(targets) >= 2: - return f"Copied branch {targets[-2]!r} to {targets[-1]!r}." + return tool_success(str(f"Copied branch {targets[-2]!r} to {targets[-1]!r}.")) name = targets[-1] if targets else "the requested branch" - return f"Created branch {name!r}." - return out if out else "(no branches)" + return tool_success(str(f"Created branch {name!r}.")) + return (tool_success(str(out)) if out else tool_success(str("(no branches)"))) except Exception as e: - return f"Error running git branch: {e}" + return tool_failure('execution_error', f"Error running git branch: {e}", retriable=False) +@legacy_text_api def git_add(self, args: str) -> str: """ Stage files for commit. Args format: "file1 file2" or "." (stage all). @@ -136,12 +141,13 @@ def git_add(self, args: str) -> str: cmd.append(f) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git add failed:\n{result.stderr}" - return "Files staged successfully." + return tool_failure('command_failed', f"Git add failed:\n{result.stderr}", retriable=False) + return tool_success(str("Files staged successfully.")) except Exception as e: - return f"Error running git add: {e}" + return tool_failure('execution_error', f"Error running git add: {e}", retriable=False) +@legacy_text_api def git_commit(self, args: str) -> str: """ Commit staged changes. Args format: '"commit message"' (quotes recommended). @@ -151,16 +157,17 @@ def git_commit(self, args: str) -> str: import shlex message = args.strip().strip('"').strip("'") if not message: - return "Error: git_commit requires a commit message." + return tool_failure('invalid_arguments', "Error: git_commit requires a commit message.", retriable=False) cmd = ["git", "-C", self._git_working_directory(), "commit", "-m", message] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git commit failed:\n{result.stderr}" - return result.stdout.strip() or "Commit successful." + return tool_failure('command_failed', f"Git commit failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Commit successful.")) except Exception as e: - return f"Error running git commit: {e}" + return tool_failure('execution_error', f"Error running git commit: {e}", retriable=False) +@legacy_text_api def git_push(self, args: str) -> str: """ Push commits to remote. Args format: "" (push current branch), @@ -173,12 +180,13 @@ def git_push(self, args: str) -> str: cmd.append(part) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: - return f"Git push failed:\n{result.stderr}" - return result.stdout.strip() or "Push successful." + return tool_failure('command_failed', f"Git push failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Push successful.")) except Exception as e: - return f"Error running git push: {e}" + return tool_failure('execution_error', f"Error running git push: {e}", retriable=False) +@legacy_text_api def git_pull(self, args: str) -> str: """ Pull changes from remote. Args format: "" (pull current branch), @@ -191,12 +199,13 @@ def git_pull(self, args: str) -> str: cmd.append(part) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: - return f"Git pull failed:\n{result.stderr}" - return result.stdout.strip() or "Already up to date." + return tool_failure('command_failed', f"Git pull failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Already up to date.")) except Exception as e: - return f"Error running git pull: {e}" + return tool_failure('execution_error', f"Error running git pull: {e}", retriable=False) +@legacy_text_api def git_checkout(self, args: str) -> str: """ Switch branches or restore files. Args format: "branch_name" (switch), @@ -209,15 +218,16 @@ def git_checkout(self, args: str) -> str: for part in args.strip().split(): cmd.append(part) else: - return "Error: git_checkout requires a branch name or argument." + return tool_failure('invalid_arguments', "Error: git_checkout requires a branch name or argument.", retriable=False) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git checkout failed:\n{result.stderr}" - return result.stdout.strip() or "Checkout successful." + return tool_failure('command_failed', f"Git checkout failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Checkout successful.")) except Exception as e: - return f"Error running git checkout: {e}" + return tool_failure('execution_error', f"Error running git checkout: {e}", retriable=False) +@legacy_text_api def git_stash(self, args: str) -> str: """ Stash or unstash working directory changes. @@ -230,12 +240,13 @@ def git_stash(self, args: str) -> str: cmd.append(args.strip()) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git stash failed:\n{result.stderr}" - return result.stdout.strip() or "Working directory clean." + return tool_failure('command_failed', f"Git stash failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Working directory clean.")) except Exception as e: - return f"Error running git stash: {e}" + return tool_failure('execution_error', f"Error running git stash: {e}", retriable=False) +@legacy_text_api def git_reset(self, args: str) -> str: """ Reset current HEAD to a specified state. @@ -249,21 +260,22 @@ def git_reset(self, args: str) -> str: for part in args.strip().split(): cmd.append(part) else: - return "Error: git_reset requires arguments (e.g., 'file' to unstage, '--soft HEAD~1' to undo commit)." + return tool_failure('invalid_arguments', "Error: git_reset requires arguments (e.g., 'file' to unstage, '--soft HEAD~1' to undo commit).", retriable=False) # Safety: block --hard without explicit confirmation path if "--hard" in cmd: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git reset failed:\n{result.stderr}" - return result.stdout.strip() or "Hard reset completed. WARNING: working directory changes were destroyed." + return tool_failure('command_failed', f"Git reset failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Hard reset completed. WARNING: working directory changes were destroyed.")) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git reset failed:\n{result.stderr}" - return result.stdout.strip() or "Reset successful." + return tool_failure('command_failed', f"Git reset failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "Reset successful.")) except Exception as e: - return f"Error running git reset: {e}" + return tool_failure('execution_error', f"Error running git reset: {e}", retriable=False) +@legacy_text_api def git_show(self, args: str) -> str: """ Show details of a git object (commit, tag, etc). Args format: "HEAD" or commit hash. @@ -274,12 +286,13 @@ def git_show(self, args: str) -> str: cmd = ["git", "-C", self._git_working_directory(), "show", "--stat", target] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git show failed:\n{result.stderr}" - return result.stdout.strip() + return tool_failure('command_failed', f"Git show failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip())) except Exception as e: - return f"Error running git show: {e}" + return tool_failure('execution_error', f"Error running git show: {e}", retriable=False) +@legacy_text_api def git_remote(self, args: str) -> str: """ Manage remote repositories. Args format: "" (list remotes), @@ -291,36 +304,37 @@ def git_remote(self, args: str) -> str: try: parts = shlex.split(args or "") except ValueError: - return "Error: git_remote arguments contain invalid shell quoting." + return tool_failure('invalid_arguments', "Error: git_remote arguments contain invalid shell quoting.", retriable=False) cmd = ["git", "-C", git_dir, "remote"] if not parts: cmd.append("-v") elif parts[0] == "add": if len(parts) != 3: - return "Error: git_remote add requires exactly: add ." + return tool_failure('invalid_arguments', "Error: git_remote add requires exactly: add .", retriable=False) cmd.extend(parts) elif parts[0] == "remove": if len(parts) != 2: - return "Error: git_remote remove requires exactly: remove ." + return tool_failure('invalid_arguments', "Error: git_remote remove requires exactly: remove .", retriable=False) cmd.extend(parts) else: - return "Error: git_remote requires empty args, add , or remove ." + return tool_failure('invalid_arguments', "Error: git_remote requires empty args, add , or remove .", retriable=False) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git remote failed:\n{result.stderr}" + return tool_failure('command_failed', f"Git remote failed:\n{result.stderr}", retriable=False) output = result.stdout.strip() if output: - return output + return tool_success(str(output)) if parts and parts[0] == "add": - return f"Remote '{parts[1]}' added." + return tool_success(str(f"Remote '{parts[1]}' added.")) if parts and parts[0] == "remove": - return f"Remote '{parts[1]}' removed." - return "(no remotes configured)" + return tool_success(str(f"Remote '{parts[1]}' removed.")) + return tool_success(str("(no remotes configured)")) except Exception as e: - return f"Error running git remote: {e}" + return tool_failure('execution_error', f"Error running git remote: {e}", retriable=False) +@legacy_text_api def git_status(self, args: str) -> str: """ Show git status of a repository. Args format: "path" (default "."). @@ -331,14 +345,15 @@ def git_status(self, args: str) -> str: cmd = ["git", "-C", abs_path, "status"] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - return f"Git status failed:\n{result.stderr}" - return result.stdout.strip() or "(clean)" + return tool_failure('command_failed', f"Git status failed:\n{result.stderr}", retriable=False) + return tool_success(str(result.stdout.strip() or "(clean)")) except Exception as e: - return f"Error running git status: {e}" + return tool_failure('execution_error', f"Error running git status: {e}", retriable=False) +@legacy_text_api def execute_terminal_command(self, args: str) -> str: """ Execute a terminal command. This is an alias for run_cmd. """ - return str(self.run_command(args)) + return command_tool_result(self.run_command(args)) diff --git a/openkyrozen/tools/github.py b/openkyrozen/tools/github.py index 404e7da..14d3c39 100644 --- a/openkyrozen/tools/github.py +++ b/openkyrozen/tools/github.py @@ -1,22 +1,26 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import json -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE +@legacy_text_api def github_status(self, args: str = "") -> str: """Inspect GitHub CLI installation and authentication without exposing tokens.""" if self._GITHUB_CLI is None: - return "Error: GitHub CLI is not configured." - return json.dumps(self._GITHUB_CLI.status(str(args).strip() or None), ensure_ascii=False, indent=2) + return tool_failure('not_found', "Error: GitHub CLI is not configured.", retriable=False) + data = self._GITHUB_CLI.status(str(args).strip() or None) + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False, indent=2)) +@legacy_text_api def github_read(self, args: str | list[str]) -> str: """Run an allowlisted read-only GitHub CLI command.""" - return self._GITHUB_CLI.run(args, read_only=True) if self._GITHUB_CLI is not None else "Error: GitHub CLI is not configured." + return (self._GITHUB_CLI.run_result(args, read_only=True) if self._GITHUB_CLI is not None else tool_failure('not_found', "Error: GitHub CLI is not configured.", retriable=False)) +@legacy_text_api def github_cli(self, args: str | list[str]) -> str: """Run GitHub CLI arguments directly without a shell. Agent mode and approval are required.""" - return self._GITHUB_CLI.run(args) if self._GITHUB_CLI is not None else "Error: GitHub CLI is not configured." + return (self._GITHUB_CLI.run_result(args) if self._GITHUB_CLI is not None else tool_failure('not_found', "Error: GitHub CLI is not configured.", retriable=False)) diff --git a/openkyrozen/tools/github_cli.py b/openkyrozen/tools/github_cli.py index 7750e9b..45d17b2 100644 --- a/openkyrozen/tools/github_cli.py +++ b/openkyrozen/tools/github_cli.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import Any, Callable +from openkyrozen.tools.models import ToolError, ToolResult, tool_failure, tool_success, render_tool_result + GH_VERSION = "2.101.0" GH_RELEASE_BASE = f"https://github.com/cli/cli/releases/download/v{GH_VERSION}" @@ -143,39 +145,44 @@ def is_read_only(argv: list[str]) -> bool: return True return len(values) >= 2 and (values[0], values[1]) in READ_ONLY - def run(self, value: str | list[str], *, read_only: bool = False) -> str: + def run_result(self, value: str | list[str], *, read_only: bool = False) -> ToolResult: binary = self.binary() if not binary: - return "AUTH_REQUIRED: GitHub CLI is not installed. Use /github login to install and authenticate it." + return tool_failure("dependency_unavailable", "AUTH_REQUIRED: GitHub CLI is not installed. Use /github login to install and authenticate it.") try: argv = self.parse_args(value) except ValueError as exc: - return f"Error: {exc}" + return tool_failure("invalid_arguments", f"Error: {exc}") if not argv: - return "Error: github_cli requires arguments." + return tool_failure("invalid_arguments", "Error: github_cli requires arguments.") if (argv[:2] == ["auth", "token"] or "--show-token" in argv or any(value.split("=", 1)[0] in SENSITIVE_FLAGS for value in argv)): - return "Error: credential arguments are blocked; let gh own authentication." + return tool_failure("permission_denied", "Error: credential arguments are blocked; let gh own authentication.") if argv[:2] == ["auth", "login"]: - return "AUTH_REQUIRED: Interactive login is available through /github login." + return tool_failure("permission_denied", "AUTH_REQUIRED: Interactive login is available through /github login.") if read_only and not self.is_read_only(argv): - return "Error: this GitHub command is not in the read-only allowlist." + return tool_failure("permission_denied", "Error: this GitHub command is not in the read-only allowlist.") status = self.status() if not status["authenticated"] and argv[:2] != ["auth", "status"]: - return f"AUTH_REQUIRED: {status['command']}" + return tool_failure("dependency_unavailable", f"AUTH_REQUIRED: {status['command']}") try: result = self.runner( [binary, *argv], cwd=str(self.workspace), capture_output=True, text=True, timeout=120, check=False, env=os.environ.copy(), ) except subprocess.TimeoutExpired: - return "Error: GitHub CLI timed out after 120 seconds." + return tool_failure("timeout", "Error: GitHub CLI timed out after 120 seconds.") except OSError as exc: - return f"Error: GitHub CLI could not start: {exc}" + return tool_failure("dependency_unavailable", f"Error: GitHub CLI could not start: {exc}") output = self._redact((result.stdout or "") + (("\n" + result.stderr) if result.stderr else "")).strip() if result.returncode: - return f"Error: gh exited with {result.returncode}: {output or '(no output)'}" - return output or "GitHub CLI completed successfully." + return ToolResult(data={"exit_code": result.returncode}, text=f"Error: gh exited with {result.returncode}: {output or '(no output)'}", + error=ToolError( + "command_failed", f"gh exited with {result.returncode}", metadata={"exit_code": result.returncode})) + return tool_success(output or "GitHub CLI completed successfully.", data={"exit_code": result.returncode}) + + def run(self, value: str | list[str], *, read_only: bool = False) -> str: + return render_tool_result(self.run_result(value, read_only=read_only)) def login_command(self, hostname: str | None = None) -> list[str] | None: binary = self.binary() diff --git a/openkyrozen/tools/graph.py b/openkyrozen/tools/graph.py index f841fe4..a4ff0ef 100644 --- a/openkyrozen/tools/graph.py +++ b/openkyrozen/tools/graph.py @@ -1,40 +1,46 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import json -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE +@legacy_text_api def graph_status(self, _args: str = "") -> str: """Return the private Graphify index state and a bounded mini graph.""" if self._PROJECT_GRAPH is None: - return "Error: project graph is not configured." - return json.dumps(self._PROJECT_GRAPH.snapshot(), ensure_ascii=False, indent=2) + return tool_failure('not_found', "Error: project graph is not configured.", retriable=False) + data = self._PROJECT_GRAPH.snapshot() + return tool_json_result(data, text=json.dumps(data, ensure_ascii=False, indent=2)) +@legacy_text_api def graph_query(self, args: str) -> str: """Query the private Graphify index. Args: a project question.""" - return self._PROJECT_GRAPH.query(args) if self._PROJECT_GRAPH is not None else "Error: project graph is not configured." + return (tool_success(str(self._PROJECT_GRAPH.query(args))) if self._PROJECT_GRAPH is not None else tool_failure('not_found', "Error: project graph is not configured.", retriable=False)) +@legacy_text_api def graph_explain(self, args: str) -> str: """Explain one graph node. Args: node label.""" - return self._PROJECT_GRAPH.explain(args) if self._PROJECT_GRAPH is not None else "Error: project graph is not configured." + return (tool_success(str(self._PROJECT_GRAPH.explain(args))) if self._PROJECT_GRAPH is not None else tool_failure('not_found', "Error: project graph is not configured.", retriable=False)) +@legacy_text_api def graph_path(self, args: str) -> str: """Trace the shortest graph path. Args: left|right.""" if self._PROJECT_GRAPH is None: - return "Error: project graph is not configured." + return tool_failure('not_found', "Error: project graph is not configured.", retriable=False) parts = str(args).split("|", 1) if len(parts) != 2: - return "Error: graph_path requires left|right." - return self._PROJECT_GRAPH.path(parts[0], parts[1]) + return tool_failure('invalid_arguments', "Error: graph_path requires left|right.", retriable=False) + return tool_success(str(self._PROJECT_GRAPH.path(parts[0], parts[1]))) +@legacy_text_api def graph_refresh(self, args: str = "") -> str: """Refresh the private local code graph. Pass --full for a clean rebuild.""" if self._PROJECT_GRAPH is None: - return "Error: project graph is not configured." + return tool_failure('not_found', "Error: project graph is not configured.", retriable=False) state = self._PROJECT_GRAPH.refresh(full=str(args).strip().lower() in {"full", "--full"}) - return json.dumps(state, ensure_ascii=False, indent=2) + return tool_json_result(state, text=json.dumps(state, ensure_ascii=False, indent=2)) diff --git a/openkyrozen/tools/manifest.py b/openkyrozen/tools/manifest.py index dcbabef..2e6f08a 100644 --- a/openkyrozen/tools/manifest.py +++ b/openkyrozen/tools/manifest.py @@ -9,6 +9,7 @@ from typing import Any, Callable from openkyrozen.security.capabilities import CapabilityToken +from .models import CommandResult, ToolResult, ToolError, render_tool_result, require_tool_result def _json_value(value): @@ -210,7 +211,11 @@ def __init__(self, registry, excluded=()): def __getitem__(self,name): if name in self.excluded: raise KeyError(name) - return self.registry.get_spec(name).executor + spec=self.registry.get_spec(name) + def render(args): + return render_tool_result(self.registry._invoke_executor(spec,args)) + render.__tool_result_executor__ = spec.executor + return render def __setitem__(self,name,function): if name in self.excluded: raise PermissionError('Tool is excluded from this view') @@ -269,6 +274,9 @@ def register_spec(self,spec,*,replace=False): def register(self,name,function,*,capability=None,risk=None,version=None,source=None): from .catalog import builtin_metadata old=self._specs.get(name) + typed=getattr(getattr(function,"__func__",function),"__tool_result_executor__",None) + if typed is not None: + function=typed.__get__(function.__self__,type(function.__self__)) if hasattr(function,"__self__") else typed if old: updates={'executor':function} updates.update({k:v for k,v in {'capability':capability,'risk':risk,'version':version,'source':source}.items() if v is not None}) @@ -301,8 +309,26 @@ def mcp_descriptors(self,token=None,*,names=None): def provider_schemas(self,token=None,*,names=None): return [{'name':s.name,'description':s.description.splitlines()[0],'parameters':s.input_schema} for s in self._visible(token,names)] - def invoke(self,name,args,token): + @staticmethod + def _invoke_executor(spec,args): + try: + result=spec.executor(args) + except Exception as exc: + return ToolResult(text=f"Error: {type(exc).__name__}", error=ToolError( + "execution_error", f"Tool execution failed ({type(exc).__name__})", + metadata={"exception_type":type(exc).__name__}, + )) + if isinstance(result, CommandResult): + from .models import command_tool_result + return command_tool_result(result) + return require_tool_result(result) + + def invoke_result(self,name,args,token): spec=self.get_spec(name) if not token.allows(spec.capability): - raise PermissionError(f"Capability '{spec.capability}' is not granted by token") - return spec.executor(args) + return ToolResult(text=f"Error: Capability '{spec.capability}' is not granted by token", + error=ToolError("permission_denied",f"Capability '{spec.capability}' is not granted by token")) + return self._invoke_executor(spec,args) + + def invoke(self,name,args,token): + return render_tool_result(self.invoke_result(name,args,token)) diff --git a/openkyrozen/tools/models.py b/openkyrozen/tools/models.py index 446c538..7993a80 100644 --- a/openkyrozen/tools/models.py +++ b/openkyrozen/tools/models.py @@ -2,6 +2,7 @@ import json import math +from functools import wraps from dataclasses import dataclass, field from typing import Any @@ -111,6 +112,69 @@ def render_tool_result(result: ToolResult) -> str: return "" +def tool_success(text: str, *, data: Any = None, metadata: dict[str, Any] | None = None) -> ToolResult: + return ToolResult(data=data, text=text, metadata=metadata) + + +def tool_json_result(data: Any, *, text: str | None = None) -> ToolResult: + rendered = json.dumps(data, ensure_ascii=False) if text is None else text + return ToolResult(data=data, text=rendered) + + +def tool_failure(code: str, text: str, *, message: str | None = None, retriable: bool = False, + data: Any = None, metadata: dict[str, Any] | None = None) -> ToolResult: + display = text if isinstance(text,str) else str(text) + error_message = message or display.removeprefix("Error: ").strip() or code.replace("_", " ") + return ToolResult(data=data, text=display, + error=ToolError(code,error_message,retriable,metadata)) + + +def require_tool_result(value: Any) -> ToolResult: + """Convert a bad executor contract into a terminal typed failure.""" + if isinstance(value, ToolResult): + return value + return ToolResult(text="Error: tool executor returned an invalid result", error=ToolError( + "contract_error", "Tool executor must return ToolResult", + metadata={"return_type": type(value).__name__}, + )) + + +def legacy_text_api(function): + """Expose a typed producer through its historical rendered string interface.""" + function.__tool_result_executor__ = function + @wraps(function) + def render(*args, **kwargs): + return render_tool_result(require_tool_result(function(*args, **kwargs))) + + render.__tool_result_executor__ = function + return render + + +def invoke_tool_result(function, *args, **kwargs) -> ToolResult: + """Call a legacy-wrapped producer through its typed implementation.""" + bound = getattr(function, "__func__", function) + typed = getattr(bound, "__tool_result_executor__", None) + if typed is None: + return require_tool_result(function(*args, **kwargs)) + owner = getattr(function, "__self__", None) + return require_tool_result(typed(owner, *args, **kwargs) if owner is not None else typed(*args, **kwargs)) + + +def command_tool_result(result: "CommandResult") -> ToolResult: + """Preserve explicit process status and exit details as a typed outcome.""" + if not isinstance(result, CommandResult): + raise TypeError("Expected CommandResult") + data = {"exit_code": result.exit_code} if result.exit_code is not None else None + if result.success: + return ToolResult(data=data, text=result.output) + code = {"nonzero_exit": "command_failed", "denied": "permission_denied"}.get( + result.failure, result.failure or "command_failed" + ) + return ToolResult(data=data, text=result.output, error=ToolError( + code, result.output or "Command failed", metadata={"exit_code": result.exit_code}, + )) + + @dataclass(frozen=True) class CommandResult: """The durable outcome of one shell command, separate from its display text.""" diff --git a/openkyrozen/tools/remote.py b/openkyrozen/tools/remote.py index 4678117..4d3c323 100644 --- a/openkyrozen/tools/remote.py +++ b/openkyrozen/tools/remote.py @@ -1,13 +1,14 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import os import subprocess import tempfile import shutil -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE +@legacy_text_api def analyze_remote_repo(self, args: str) -> str: """ Clone a remote git repository, read its files, and return a summary. @@ -15,7 +16,7 @@ def analyze_remote_repo(self, args: str) -> str: """ url = args.strip() if not url: - return "Error: no URL provided." + return tool_failure('invalid_arguments', "Error: no URL provided.", retriable=False) try: import tempfile import shutil @@ -26,7 +27,7 @@ def analyze_remote_repo(self, args: str) -> str: ) if proc.returncode != 0: shutil.rmtree(tmp_dir) - return f"Clone failed:\n{proc.stderr}" + return tool_failure('command_failed', f"Clone failed:\n{proc.stderr}", retriable=False) summaries = [] for root, dirs, files in os.walk(tmp_dir): dirs[:] = [d for d in dirs if d != ".git"] @@ -46,6 +47,6 @@ def analyze_remote_repo(self, args: str) -> str: if not summaries: summaries.append("(empty repo or all files binary)") shutil.rmtree(tmp_dir) - return "\n\n".join(summaries) + return tool_success(str("\n\n".join(summaries))) except Exception as e: - return f"Error analyzing repository: {e}" + return tool_failure('execution_error', f"Error analyzing repository: {e}", retriable=False) diff --git a/openkyrozen/tools/search.py b/openkyrozen/tools/search.py index 13701ed..944a5e3 100644 --- a/openkyrozen/tools/search.py +++ b/openkyrozen/tools/search.py @@ -1,12 +1,13 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import json import re import requests -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE +@legacy_text_api def search_web(self, args: str) -> str: """ Search the internet for real-time information. @@ -21,7 +22,7 @@ def search_web(self, args: str) -> str: query = (args or "").strip() if not query: - return "Search Error: query is empty." + return tool_failure('invalid_arguments', "Search Error: query is empty.", retriable=False) def _fmt(results): lines = [] @@ -74,7 +75,7 @@ def _fmt(results): snippet = html.unescape(m2.group(1)).strip() results.append({"title": title, "body": snippet, "url": url}) if results: - return _fmt(results) + return tool_success(str(_fmt(results))) # ---- attempt 2: DuckDuckGo Lite HTML (GET) ---- lite_url = f"https://lite.duckduckgo.com/lite/?q={quote(query)}" @@ -95,7 +96,7 @@ def _fmt(results): snippet = html.unescape(snippet) results.append({"title": title, "body": snippet, "url": ""}) if results: - return _fmt(results) + return tool_success(str(_fmt(results))) except Exception: pass @@ -118,7 +119,7 @@ def _fmt(results): snippet = html.unescape(snippet) results.append({"title": title, "body": snippet, "url": ""}) if results: - return _fmt(results) + return tool_success(str(_fmt(results))) except Exception: pass @@ -139,7 +140,7 @@ def _fmt(results): title = title.split(" - ")[0] results.append({"title": title[:200], "body": "", "url": url}) if results: - return _fmt(results) + return tool_success(str(_fmt(results))) except Exception: pass @@ -156,14 +157,14 @@ def _fmt(results): url = data[3][i] if i < len(data[3]) else "" results.append({"title": title, "body": desc[:300], "url": url}) if results: - return _fmt(results) + return tool_success(str(_fmt(results))) except Exception: pass # All backends failed — return a useful error message return ( - "Search temporarily unavailable: all backends (Google, DuckDuckGo, Wikipedia) " + tool_failure('dependency_unavailable', "Search temporarily unavailable: all backends (Google, DuckDuckGo, Wikipedia) " f"failed for query '{query[:80]}'. This may be due to rate-limiting or " "network issues. Suggestions: try a shorter query, wait a few seconds and " - "retry, or use read_webpage on a known URL instead." + "retry, or use read_webpage on a known URL instead.", retriable=True) ) diff --git a/openkyrozen/tools/shell.py b/openkyrozen/tools/shell.py index bd1ea16..d6017a4 100644 --- a/openkyrozen/tools/shell.py +++ b/openkyrozen/tools/shell.py @@ -1,11 +1,11 @@ from __future__ import annotations +from openkyrozen.tools.models import CommandResult, ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import os import subprocess import sys from pathlib import Path -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE def _is_dangerous(self, cmd: str) -> bool: @@ -65,6 +65,7 @@ def run_command(self, args: str) -> CommandResult: return CommandResult(f"Error running command: {e}", False, failure="execution_error") +@legacy_text_api def run_cmd(self, args: str) -> str: """Execute a shell command. Args: the full command string.""" - return str(self.run_command(args)) + return command_tool_result(self.run_command(args)) diff --git a/openkyrozen/tools/web.py b/openkyrozen/tools/web.py index 2103ab5..39ca598 100644 --- a/openkyrozen/tools/web.py +++ b/openkyrozen/tools/web.py @@ -1,4 +1,5 @@ from __future__ import annotations +from openkyrozen.tools.models import ToolError, ToolResult, command_tool_result, legacy_text_api, tool_failure, tool_json_result, tool_success import re import http.client @@ -8,7 +9,6 @@ from typing import Any from urllib.parse import urljoin, urlsplit -from openkyrozen.tools.models import CommandResult from openkyrozen.security.command_policy import _BLOCKED_RE _MAX_WEBPAGE_BYTES = 1_000_000 @@ -160,6 +160,7 @@ def _read_pinned_web_response(self, parsed: Any, hostname: str, port: int, addre connection.close() +@legacy_text_api def read_webpage(self, args: str) -> str: """ Fetch the content of a web page and return its plain‑text body. @@ -169,7 +170,7 @@ def read_webpage(self, args: str) -> str: import re url = str(args or "").strip() if not url: - return "Error: read_webpage requires a valid URL." + return tool_failure('invalid_arguments', "Error: read_webpage requires a valid URL.", retriable=False) try: current_url = url for redirect_count in range(_MAX_WEBPAGE_REDIRECTS + 1): @@ -177,22 +178,22 @@ def read_webpage(self, args: str) -> str: status, location, body = self._read_pinned_web_response(parsed, hostname, port, address) if status in {301, 302, 303, 307, 308}: if not location: - return f"Error fetching webpage: HTTP {status} redirect has no Location header" + return tool_failure('network_error', f"Error fetching webpage: HTTP {status} redirect has no Location header", retriable=True) if redirect_count >= _MAX_WEBPAGE_REDIRECTS: - return f"Error fetching webpage: too many redirects (maximum {_MAX_WEBPAGE_REDIRECTS})" + return tool_failure('network_error', f"Error fetching webpage: too many redirects (maximum {_MAX_WEBPAGE_REDIRECTS})", retriable=True) current_url = urljoin(current_url, location) continue if status < 200 or status >= 400: - return f"Error fetching webpage: HTTP {status}" + return tool_failure('network_error', f"Error fetching webpage: HTTP {status}", retriable=True) text = re.sub(r"<[^>]*>", "", body) text = html.unescape(text) text = re.sub(r"\s+", " ", text).strip() if len(text) > 5000: text = text[:5000] + "\n[...truncated...]" - return text - return f"Error fetching webpage: too many redirects (maximum {_MAX_WEBPAGE_REDIRECTS})" + return tool_success(str(text)) + return tool_failure('network_error', f"Error fetching webpage: too many redirects (maximum {_MAX_WEBPAGE_REDIRECTS})", retriable=True) except (OSError, http.client.HTTPException, ValueError) as e: message = str(e) if "blocked address" in message or "only http" in message or "credentials in URLs" in message: - return f"Error: URL blocked: {message}" - return f"Error fetching webpage: {message}" + return tool_failure('permission_denied', f"Error: URL blocked: {message}", retriable=False) + return tool_failure('network_error', f"Error fetching webpage: {message}", retriable=True) diff --git a/tests/test_architecture.py b/tests/test_architecture.py index c7d54e5..7d510e9 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -6,6 +6,7 @@ import tempfile import threading import unittest +from openkyrozen.tools.models import ToolResult from pathlib import Path from unittest.mock import patch @@ -98,7 +99,7 @@ def test_parallel_execution_keeps_turn_tokens_and_sessions_local(self): def read(_args): runtime.DEEPSEEK_MODEL = runtime.current_session.session_id barrier.wait(timeout=5) - return runtime.current_session.session_id + ":" + runtime.DEEPSEEK_MODEL + return ToolResult(text=runtime.current_session.session_id + ":" + runtime.DEEPSEEK_MODEL) runtime.AVAILABLE_TOOLS["read_file"] = read try: diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 2ad82c4..24c4876 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -18,6 +18,26 @@ def test_browser_url_boundary(self): with patch.dict("os.environ", {"KYROZEN_BROWSER_ALLOW_PRIVATE": ""}): self.assertFalse(validate_browser_url("http://127.0.0.1:8000")[0]) + def test_click_propagates_snapshot_failure_as_typed_error(self): + from openkyrozen.tools.models import invoke_tool_result + class Page: + url = "https://example.com" + def title(self): + return "Example" + def locator(self, _selector): + return Locator() + class Locator: + def click(self, **_kwargs): + return None + def inner_text(self, **_kwargs): + raise RuntimeError("snapshot failed") + manager = BrowserManager() + manager._sessions["session"] = (None, None, Page()) + result = invoke_tool_result(manager.click, "session|button") + self.assertFalse(result.success) + self.assertEqual(result.error.code, "execution_error") + self.assertIn("snapshot failed", result.text) + def test_missing_playwright_is_actionable(self): with tempfile.TemporaryDirectory() as directory: manager = BrowserManager(root=directory) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 0b44a37..5980b65 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -4,6 +4,7 @@ from openkyrozen.security.capabilities import CapabilityToken, issue_capability_token from openkyrozen.app.bootstrap import build_application +from openkyrozen.tools.models import ToolResult _application = build_application(surface="cli") main = _application.runtime from openkyrozen.tools.manifest import ToolRegistry @@ -12,13 +13,14 @@ class CapabilityTests(unittest.TestCase): def test_token_expires_and_registry_filters_and_blocks_tools(self): registry = ToolRegistry() - registry.register("read", lambda args: "ok", capability="read") - registry.register("write", lambda args: "changed", capability="write") + registry.register("read", lambda args: ToolResult(text="ok"), capability="read") + registry.register("write", lambda args: ToolResult(text="changed"), capability="write") token = issue_capability_token("test", {"read"}, ttl_seconds=1) self.assertEqual([item["name"] for item in registry.catalog(token)], ["read"]) self.assertEqual(registry.invoke("read", "", token), "ok") - with self.assertRaises(PermissionError): - registry.invoke("write", "", token) + denied = registry.invoke_result("write", "", token) + self.assertFalse(denied.success) + self.assertEqual(denied.error.code, "permission_denied") self.assertTrue(token.allows("read")) time.sleep(1.05) self.assertFalse(token.allows("read")) diff --git a/tests/test_dynamic_tools.py b/tests/test_dynamic_tools.py index 78aa3bc..c77e60a 100644 --- a/tests/test_dynamic_tools.py +++ b/tests/test_dynamic_tools.py @@ -33,7 +33,7 @@ def tearDown(self): self._interaction_directory.cleanup() def test_static_validator_accepts_small_pure_tool(self): - valid, reason = validate_tool_source("def add(args):\n return str(len(args))\n", "add") + valid, reason = validate_tool_source("def add(args):\n return ToolResult(data=len(args), text=str(len(args)))\n", "add") self.assertTrue(valid, reason) def test_static_validator_rejects_imports_and_process_access(self): @@ -43,8 +43,8 @@ def test_static_validator_rejects_imports_and_process_access(self): def test_chat_observer_registers_and_runs_a_real_dynamic_tool(self): source = ( "DefineTool:\n```python\n" - "def count_chars(args: str) -> str:\n" - " return str(len(args))\n" + "def count_chars(args: str) -> ToolResult:\n" + " return ToolResult(data=len(args), text=str(len(args)))\n" "```" ) original_tools = dict(main.AVAILABLE_TOOLS) @@ -91,8 +91,8 @@ def artifact_context(self, _run): source = ( "DefineTool:\n```python\n" - "def count_chars(args: str) -> str:\n" - " return str(len(args))\n" + "def count_chars(args: str) -> ToolResult:\n" + " return ToolResult(data=len(args), text=str(len(args)))\n" "```" ) responses = [ @@ -148,8 +148,8 @@ def artifact_context(self, _run): source = ( "DefineTool:\n```python\n" - "def count_chars(args: str) -> str:\n" - " return str(len(args))\n" + "def count_chars(args: str) -> ToolResult:\n" + " return ToolResult(data=len(args), text=str(len(args)))\n" "```" ) original_tools = dict(main.AVAILABLE_TOOLS) diff --git a/tests/test_github_cli.py b/tests/test_github_cli.py index 467ac73..28b7001 100644 --- a/tests/test_github_cli.py +++ b/tests/test_github_cli.py @@ -15,6 +15,7 @@ class Runner: def __init__(self): self.calls = [] self.authenticated = True + self.returncode = 0 def __call__(self, command, **kwargs): self.calls.append(command) @@ -25,7 +26,7 @@ def __call__(self, command, **kwargs): return subprocess.CompletedProcess(command, code, "", "token=ghp_abcdefghijklmnopqrstuvwxyz123456") if command[1:3] == ["auth", "login"]: return subprocess.CompletedProcess(command, 1, "", "cancelled") - return subprocess.CompletedProcess(command, 0, "ok", "") + return subprocess.CompletedProcess(command, self.returncode, "ok", "rejected" if self.returncode else "") class GitHubCLITests(unittest.TestCase): @@ -42,6 +43,12 @@ def test_enterprise_status_redacts_tokens_and_read_allowlist_is_shell_free(self) self.assertTrue(status["authenticated"]) self.assertNotIn("ghp_", status["message"]) self.assertEqual(client.run(["pr", "view"], read_only=True), "ok") + self.assertTrue(client.run_result(["pr", "view"], read_only=True).success) + runner.returncode = 1 + failed = client.run_result(["pr", "view"], read_only=True) + self.assertFalse(failed.success) + self.assertEqual(failed.error.code, "command_failed") + runner.returncode = 0 before = len(runner.calls) self.assertIn("allowlist", client.run(["pr", "merge"], read_only=True)) self.assertEqual(len(runner.calls), before) diff --git a/tests/test_interaction.py b/tests/test_interaction.py index e2b532f..501df07 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,6 +1,7 @@ import json import tempfile import unittest +from openkyrozen.tools.models import ToolResult from pathlib import Path from unittest.mock import patch @@ -656,7 +657,7 @@ def test_read_only_modes_deny_mutating_and_dynamic_tools(self): "plan", ), ) - self.agent.AVAILABLE_TOOLS["temporary_dynamic_tool"] = lambda args: f"ran:{args}" + self.agent.AVAILABLE_TOOLS["temporary_dynamic_tool"] = lambda args: ToolResult(text=f"ran:{args}") try: self.assertIn("safe", self.agent._run_tool("read_file", "notes.txt")) for action, args in ( @@ -711,7 +712,7 @@ def test_accepted_plan_rejects_unrelated_mutation_before_execution(self): successful_operations=operations, ) self.assertFalse(rejected.success) - self.assertEqual(rejected.failure, "plan_action_mismatch") + self.assertEqual(rejected.failure, "permission_denied") self.assertFalse((root / "README.md").exists()) accepted = self.agent._execute_turn_action( diff --git a/tests/test_learning_dispatcher.py b/tests/test_learning_dispatcher.py index 6afff67..b912073 100644 --- a/tests/test_learning_dispatcher.py +++ b/tests/test_learning_dispatcher.py @@ -5,6 +5,7 @@ import tempfile import threading import unittest +from openkyrozen.tools.models import ToolResult from pathlib import Path from types import SimpleNamespace from unittest.mock import ANY, Mock, patch @@ -137,7 +138,7 @@ def test_dynamic_tool_learning_never_grants_capability(self): with tempfile.TemporaryDirectory() as directory: self._isolated_runtime(Path(directory)) stats = {"audit_probe": {"calls": 2, "successes": 2}} - with patch.dict(main.AVAILABLE_TOOLS, {"audit_probe": lambda _args: "ok"}), \ + with patch.dict(main.AVAILABLE_TOOLS, {"audit_probe": lambda _args: ToolResult(text="ok")}), \ patch.object(main, "_tool_stats", stats), \ patch.object(main, "ALLOW_DYNAMIC_TOOLS", True), \ patch.object(main.learning_engine, "_review_claim_evidence", return_value=None): @@ -349,7 +350,7 @@ def test_autonomous_inspection_and_technology_research_reach_recall(self): self.assertIn("TODO", main._build_memory_context("Orion parser TODO", n=8)) main._set_learning_runtime("remote", "ready") - with patch.dict(main.AVAILABLE_TOOLS, {"search_web": lambda _query: "- Title: Orionlib documentation\n Usage: parser API"}), \ + with patch.dict(main.AVAILABLE_TOOLS, {"search_web": lambda _query: ToolResult(text="- Title: Orionlib documentation\n Usage: parser API")}), \ patch.object(main._technology_executor, "submit", side_effect=lambda fn, lib: fn(lib)): main.dispatch_learning_cycle(surface="test", trigger="turn", max_features=1, user_input="use orionlib for parser work", feature_names=("auto_patch_technology",)) @@ -375,7 +376,7 @@ def test_tool_observations_survive_worker_restart_without_arguments(self): root = Path(directory) self._isolated_runtime(root) for _ in range(3): - main._track_tool_performance("parse_tool", "Error: failed", 0.1) + main._track_tool_performance("parse_tool", False, 0.1) self._isolated_runtime(root) with patch.object(main, "_tool_stats", {}): stats = main._learning_tool_stats() diff --git a/tests/test_task_consistency.py b/tests/test_task_consistency.py index 276eee6..7e17c38 100644 --- a/tests/test_task_consistency.py +++ b/tests/test_task_consistency.py @@ -4,6 +4,7 @@ import tempfile import time import unittest +from openkyrozen.tools.models import ToolResult from pathlib import Path from unittest.mock import patch @@ -355,10 +356,10 @@ def artifact_context(self, _run): original_tasks = main.tasks original_learning = main.learning_engine main._set_workspace_root(root) - original_write = main.AVAILABLE_TOOLS["write_file"] + original_write = main.tool_registry.get_spec("write_file").executor writes: list[str] = [] - def counted_write(args: str) -> str: + def counted_write(args: str) -> ToolResult: writes.append(args) return original_write(args) @@ -986,7 +987,7 @@ def test_failed_operation_can_retry_and_provider_deadline_is_honest(self): failed = main._execute_turn_action("run_cmd", "python -c pass", operation_scope="retry", successful_operations=operations) retried = main._execute_turn_action("run_cmd", "python -c pass", operation_scope="retry", successful_operations=operations) self.assertFalse(failed.success) - self.assertEqual(failed.failure, "nonzero_exit") + self.assertEqual(failed.failure, "command_failed") self.assertTrue(retried.success) class SlowProvider: diff --git a/tests/test_tool_discovery.py b/tests/test_tool_discovery.py index c3bd333..0bbd682 100644 --- a/tests/test_tool_discovery.py +++ b/tests/test_tool_discovery.py @@ -1,6 +1,7 @@ import os import tempfile import unittest +from openkyrozen.tools.models import ToolResult from pathlib import Path from unittest.mock import patch @@ -77,7 +78,7 @@ def turn(runtime, name): self.assertNotIn("- git_log:", turn(self.agent, "git_show")) with self.agent.use_session(second): self.assertNotIn("- git_log:", turn(self.agent, "git_show")) - self.agent.AVAILABLE_TOOLS["pure_helper"] = lambda args: args.strip() + self.agent.AVAILABLE_TOOLS["pure_helper"] = lambda args: ToolResult(text=args.strip()) self.assertIn("pure_helper", self.agent._agent_prompt_tools_list(self.config)) self.assertIn("- pure_helper:", self.agent._discover_tools("pure_helper")) self.agent._execution_capability_token = issue_capability_token("test", frozenset({"read"})) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index f0bed7d..dbdc4bb 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -1,4 +1,5 @@ import unittest +from openkyrozen.tools.models import ToolResult from openkyrozen.tools.manifest import ToolRegistry from openkyrozen.tools import ToolAdapters @@ -8,7 +9,8 @@ def test_every_adapter_has_complete_spec(self): self.assertEqual(len(adapters.AVAILABLE_TOOLS),40) for name, executor in adapters.AVAILABLE_TOOLS.items(): spec = adapters.tool_registry.get_spec(name) - self.assertIs(spec.executor,executor) + self.assertTrue(callable(spec.executor)) + self.assertTrue(getattr(spec.executor, "__tool_result_executor__", spec.executor)) self.assertTrue(spec.description) self.assertEqual(spec.input_schema['type'],'object') self.assertTrue(spec.capability) @@ -17,7 +19,7 @@ def test_every_adapter_has_complete_spec(self): def test_compatibility_registration_has_restrictive_metadata(self): adapters = ToolAdapters() - adapters.AVAILABLE_TOOLS['new_helper'] = lambda args: args + adapters.AVAILABLE_TOOLS['new_helper'] = lambda args: ToolResult(text=args) spec = adapters.tool_registry.get_spec('new_helper') self.assertEqual(spec.capability,'dynamic') self.assertFalse(spec.parallel_safe) @@ -39,7 +41,7 @@ def make(self,name='custom',**changes): values=dict(description='Custom helper.',input_schema={'type':'object','properties':{'args':{'type':'string'}},'additionalProperties':False}, capability='read',side_effects='read',parallel_safe=False,aliases=('helper_alias',),source='test',version='2.0.0') values.update(changes) - return ToolSpec(name,lambda args:args,**values) + return ToolSpec(name,lambda args:ToolResult(text=args),**values) def test_collision_and_invalid_registration_are_atomic(self): registry=ToolRegistry() @@ -60,11 +62,15 @@ def test_snapshot_restore_preserves_complete_metadata(self): spec=self.make(visibility='hidden',risk='high') registry.register_spec(spec) snapshot=registry.snapshot() - registry.tools['custom']=lambda args:'changed' + registry.tools['custom']=lambda args:ToolResult(text='changed') self.assertEqual(registry.get_spec('custom').manifest,spec.manifest) del registry.tools['custom'] registry.register_spec(snapshot['custom']) self.assertIs(registry.get_spec('custom'),spec) + adapters = ToolAdapters() + original = adapters.tool_registry.get_spec('read_file').executor + adapters.AVAILABLE_TOOLS['read_file'] = adapters.AVAILABLE_TOOLS['read_file'] + self.assertIs(adapters.tool_registry.get_spec('read_file').executor, original) self.assertEqual(registry.mcp_descriptors(),[]) def test_specs_are_immutable_including_schema_and_aliases(self): @@ -98,10 +104,12 @@ def test_all48_bound_tools_and_workspace_isolation(self): runtime=app.runtime self.assertEqual(len(runtime.AVAILABLE_TOOLS),48) self.assertEqual(set(runtime.AVAILABLE_TOOLS),set(runtime.tool_registry.specs)) + self.assertTrue(all(getattr(spec.executor, "__tool_result_executor__", None) + for spec in runtime.tool_registry.specs.values())) first=Path(home)/'first';second=Path(home)/'second';first.mkdir();second.mkdir() runtime._set_workspace_root(first) original=runtime.tool_registry - runtime.AVAILABLE_TOOLS['private_helper']=lambda args:'first' + runtime.AVAILABLE_TOOLS['private_helper']=lambda args:ToolResult(text='first') runtime._set_workspace_root(second) self.assertNotIn('private_helper',runtime.AVAILABLE_TOOLS) self.assertIsNot(runtime.tool_registry,original) @@ -138,10 +146,10 @@ def test_custom_risk_metadata_reaches_permission_gate(self): class ViewAuthorizationRegressions(unittest.TestCase): def test_filtered_mapping_cannot_overwrite_hidden_executor(self): from openkyrozen.tools.manifest import ToolMapping - registry=ToolRegistry({'spawn_agents':lambda args:'original'}) + registry=ToolRegistry({'spawn_agents':lambda args:ToolResult(text='original')}) child=ToolMapping(registry,excluded=('spawn_agents',)) with self.assertRaises(PermissionError): - child['spawn_agents']=lambda args:'overwritten' + child['spawn_agents']=lambda args:ToolResult(text='overwritten') self.assertEqual(registry.tools['spawn_agents'](''),'original') class ReviewRegressions(unittest.TestCase): diff --git a/tests/test_tool_results.py b/tests/test_tool_results.py index f75453b..e3106eb 100644 --- a/tests/test_tool_results.py +++ b/tests/test_tool_results.py @@ -55,3 +55,96 @@ def test_renderer_preserves_text_and_renders_structured_fallback(self): self.assertEqual(render_tool_result(ToolResult(text="Error: valid text", error=None)), "Error: valid text") self.assertEqual(render_tool_result(ToolResult(error=ToolError("not_found", "Missing"))), "Error: Missing") self.assertEqual(render_tool_result(ToolResult(data={"count": 2})), '{"count": 2}') + +class TypedRegistryIntegrationTests(unittest.TestCase): + def test_registry_returns_typed_outcomes_and_rejects_untyped_values_once(self): + from openkyrozen.security.capabilities import issue_capability_token + from openkyrozen.tools import ToolRegistry, ToolSpec + + def make(executor): + return ToolSpec("sample", executor, description="Sample.", + input_schema={"type": "object", "properties": {"args": {"type": "string"}}, + "additionalProperties": False}, capability="read", side_effects="read") + + registry = ToolRegistry() + registry.register_spec(make(lambda _args: ToolResult(text="Error: valid content"))) + token = issue_capability_token("test", {"read"}) + result = registry.invoke_result("sample", "", token) + self.assertTrue(result.success) + self.assertEqual(registry.invoke("sample", "", token), "Error: valid content") + + calls = [] + registry.register_spec(make(lambda _args: calls.append("called") or "looks successful"), replace=True) + result = registry.invoke_result("sample", "", token) + self.assertFalse(result.success) + self.assertEqual(result.error.code, "contract_error") + self.assertEqual(calls, ["called"]) + + def test_runtime_does_not_classify_successful_error_prefixed_file_content(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): + root = Path(directory) / "workspace" + root.mkdir() + (root / "note.txt").write_text("Error: this is the file's content", encoding="utf-8") + app = build_application(memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(root)) + try: + app.runtime._set_workspace_root(root) + receipt = app.runtime.execute(app.runtime.current_session, "read_file", "note.txt", capabilities={"read"}) + self.assertTrue(receipt.success) + self.assertIsNone(receipt.failure) + self.assertEqual(receipt.result, "Error: this is the file's content") + finally: + app.close() + + def test_runtime_uses_command_status_even_when_failure_text_has_no_error_marker(self): + import tempfile + from pathlib import Path + from unittest.mock import patch + from openkyrozen.app.bootstrap import build_application, build_memory + from openkyrozen.tools import ToolAdapters + from openkyrozen.tools.models import CommandResult + + with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"KYROZEN_DISABLE_VECTOR_INDEX": "1"}): + app = build_application(memory=build_memory(Path(directory) / "state.sqlite3"), tools=ToolAdapters(directory)) + try: + with patch.object(app.runtime, "run_command", return_value=CommandResult("permission denied", False, 1)): + result, success = app.runtime._run_tool("run_cmd", "false", return_success=True) + self.assertEqual(result, "permission denied") + self.assertFalse(success) + finally: + app.close() + +class TypedObserverTests(unittest.TestCase): + def test_plugin_status_comes_from_typed_outcome_not_rendered_text(self): + from types import SimpleNamespace + from unittest.mock import Mock, patch + from openkyrozen.agent.executor import _notify_tool_execute + recorded = Mock() + runtime = SimpleNamespace( + _plugin_runtime_for_surface=lambda: SimpleNamespace(tool_execute=recorded), + memory_bank=SimpleNamespace(user_id="u", workspace_id="w", session_id="s"), + ) + with patch("openkyrozen.agent.executor.render_tool_result", return_value="Error: this text is successful content"): + _notify_tool_execute(runtime, "read_file", "x", ToolResult(text="normal")) + self.assertTrue(recorded.call_args.kwargs["success"]) + self.assertIsNone(recorded.call_args.kwargs["error"]) + self.assertEqual(recorded.call_args.kwargs["result"], "Error: this text is successful content") + + def test_learning_stats_use_explicit_outcome_boolean(self): + from types import SimpleNamespace + from openkyrozen.learning.failures import _track_tool_performance + events = [] + runtime = SimpleNamespace( + _tool_stats={}, + _record_learning_event=lambda name, payload: events.append((name, payload)), + ) + _track_tool_performance(runtime, "read_file", True, 0.2) + _track_tool_performance(runtime, "write_file", False, 0.3) + self.assertEqual(runtime._tool_stats["read_file"]["successes"], 1) + self.assertEqual(runtime._tool_stats["write_file"]["successes"], 0) + self.assertEqual([event[1]["success"] for event in events], [True, False]) From dadb7a983bae71656fc078aae592b7e7af390035 Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 11 Oct 2026 20:58:28 +0800 Subject: [PATCH 3/3] fix: address typed tool result review findings --- openkyrozen/agent/delegation_runtime.py | 29 +++++++++-- openkyrozen/agent/executor.py | 44 +++++++++++++++-- openkyrozen/tools/adapters.py | 4 +- openkyrozen/tools/browser_manager.py | 3 +- openkyrozen/tools/filesystem.py | 2 + openkyrozen/tools/git.py | 28 +++++------ openkyrozen/tools/graph.py | 13 +++-- openkyrozen/tools/manifest.py | 11 +++-- openkyrozen/workspace/graph.py | 45 +++++++++++++---- tests/test_browser_manager.py | 19 ++++++++ tests/test_project_graph.py | 15 ++++++ tests/test_task_consistency.py | 4 +- tests/test_tool_registry.py | 11 +++++ tests/test_tool_results.py | 64 +++++++++++++++++++++++++ 14 files changed, 249 insertions(+), 43 deletions(-) diff --git a/openkyrozen/agent/delegation_runtime.py b/openkyrozen/agent/delegation_runtime.py index 94ab436..aa15ea0 100644 --- a/openkyrozen/agent/delegation_runtime.py +++ b/openkyrozen/agent/delegation_runtime.py @@ -111,19 +111,31 @@ def _json_args(args): return value +def _typed_json_args(args): + try: + return _json_args(args), None + except (TypeError, ValueError) as exc: + return None, tool_failure("invalid_arguments", f"Error: invalid delegation arguments: {exc}") + + @legacy_text_api def spawn_agents(self, args): """Automatically start parallel specialist assignments; args is a JSON assignment batch.""" if self.execution_context.child_run_id: raise ValueError("Only the main agent can delegate") - data = {"agents": self.delegation().spawn(_json_args(args).get("assignments"))} + value, error = _typed_json_args(args) + if error: + return error + data = {"agents": self.delegation().spawn(value.get("assignments"))} return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) @legacy_text_api def send_subagent(self, args): """Reuse a finished sub-agent; args JSON contains run_id and a complete assignment.""" - value = _json_args(args) + value, error = _typed_json_args(args) + if error: + return error data = self.delegation().send(value.get("run_id"), value.get("assignment")) return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) @@ -131,7 +143,9 @@ def send_subagent(self, args): @legacy_text_api def list_subagents(self, args): """List agents or inspect one run_id from this chat/project; args is JSON.""" - value = _json_args(args) + value, error = _typed_json_args(args) + if error: + return error coordinator = self.delegation() data = coordinator.detail(value["run_id"]) if value.get("run_id") else { "agents": [coordinator.summary(run, results=True) for run in coordinator.list()]} @@ -141,7 +155,9 @@ def list_subagents(self, args): @legacy_text_api def wait_subagents(self, args): """Wait up to 60 seconds for delegated work and its mandatory reviews; args is JSON.""" - value = _json_args(args) + value, error = _typed_json_args(args) + if error: + return error coordinator = self.delegation() data = {"agents": [coordinator.summary(run, results=True) for run in coordinator.wait(value.get("run_ids"), value.get("timeout", 30))]} return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) @@ -150,7 +166,10 @@ def wait_subagents(self, args): @legacy_text_api def cancel_subagent(self, args): """Cancel a scoped sub-agent and prevent subsequent tool execution; args JSON contains run_id.""" - data = self.delegation().cancel(_json_args(args).get("run_id")) + value, error = _typed_json_args(args) + if error: + return error + data = self.delegation().cancel(value.get("run_id")) return tool_json_result(data, text=json.dumps(data, ensure_ascii=False)) diff --git a/openkyrozen/agent/executor.py b/openkyrozen/agent/executor.py index 2b31d73..d3c4bbc 100644 --- a/openkyrozen/agent/executor.py +++ b/openkyrozen/agent/executor.py @@ -3,6 +3,7 @@ import hashlib import ast import json +import re import uuid import time from typing import Any @@ -71,6 +72,40 @@ def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, s acceptance, accepted = self._acceptance_for_tool(canonical, str(args), result_text, success=success) if not accepted and effect_verified: acceptance = effect + + def safe_metadata(value, budget=4000): + def clean(item, remaining, depth=0): + if remaining <= 0: + return "", 0 + if isinstance(item, dict) and depth < 5: + result = {} + for key, nested in list(item.items())[:40]: + key = str(key)[:100] + if re.search(r"(?i)(api.?key|credential|password|secret|token|authorization|request.?body|^body$)", key): + result[key] = "" + remaining -= len(key) + 12 + else: + result[key], remaining = clean(nested, remaining - len(key), depth + 1) + if remaining <= 0: + break + return result, remaining + if isinstance(item, list) and depth < 5: + result = [] + for nested in item[:40]: + value, remaining = clean(nested, remaining, depth + 1) + result.append(value) + if remaining <= 0: + break + return result, remaining + if isinstance(item, str): + value = self._fix_safe_text(item, min(500, remaining), preserve_lines=True) + return value, remaining - len(value) + if item is None or type(item) in {bool, int, float}: + return item, remaining - 16 + value = self._fix_safe_text(item, min(200, remaining)) + return value, remaining - len(value) + return clean(value, budget)[0] + return ExecutionReceipt( receipt_id=f"receipt_{uuid.uuid4().hex}", operation_id=self._operation_id(operation_scope, canonical, args), @@ -89,7 +124,7 @@ def _make_execution_receipt(self, *, action: str, args: Any, authorized: bool, s verified_effect=effect if effect_verified else None, acceptance=acceptance if success and (accepted or effect_verified) else None, tool_error=({"code": tool_error.code, "message": self._fix_safe_text(tool_error.message, 1000), - "retriable": tool_error.retriable, "metadata": tool_error.metadata} if tool_error else None), + "retriable": tool_error.retriable, "metadata": safe_metadata(tool_error.metadata)} if tool_error else None), ) @@ -210,11 +245,12 @@ def _execute_turn_action(self, action: str, args: Any, *, operation_scope: str, ) if (canonical != "run_cmd" and self._is_state_changing_action(canonical, args) and operation_id in successful_operations): - result = "Error: duplicate successful state-changing action refused for this turn." - self._notify_tool_execute(canonical, args, tool_failure("permission_denied", result)) + outcome = tool_failure("conflict", "Error: duplicate successful state-changing action refused for this turn.") + result = render_tool_result(outcome) + self._notify_tool_execute(canonical, args, outcome) return self._make_execution_receipt( action=canonical, args=args, authorized=True, started_at=started_at, success=False, - result=result, operation_scope=operation_scope, failure="duplicate_operation", + result=result, operation_scope=operation_scope, failure=outcome.error.code, tool_error=outcome.error, ) receipt = self._run_tool(canonical, args, return_receipt=True, operation_scope=operation_scope) assert isinstance(receipt, ExecutionReceipt) diff --git a/openkyrozen/tools/adapters.py b/openkyrozen/tools/adapters.py index a652395..fe831dd 100644 --- a/openkyrozen/tools/adapters.py +++ b/openkyrozen/tools/adapters.py @@ -32,13 +32,13 @@ def AVAILABLE_TOOLS(self): @AVAILABLE_TOOLS.setter def AVAILABLE_TOOLS(self,value): - from .manifest import ToolRegistry + from .manifest import ToolRegistry, typed_executor previous = self.tool_registry.snapshot() if hasattr(self,'tool_registry') else {} registry = ToolRegistry() registry.runtime_bindings_initialized = getattr(getattr(self,'tool_registry',None),'runtime_bindings_initialized',False) for name,function in value.items(): if name in previous: - registry.register_spec(previous[name].replace(executor=function)) + registry.register_spec(previous[name].replace(executor=typed_executor(function))) else: registry.register(name,function) self.tool_registry = registry diff --git a/openkyrozen/tools/browser_manager.py b/openkyrozen/tools/browser_manager.py index ebfa485..82ec7b3 100644 --- a/openkyrozen/tools/browser_manager.py +++ b/openkyrozen/tools/browser_manager.py @@ -69,7 +69,8 @@ def open(self, url: str) -> str: playwright.stop() except Exception: pass - return tool_failure('execution_error', f"Error opening browser: {exc}", retriable=False) + code = 'dependency_unavailable' if 'executable doesn\'t exist' in str(exc).lower() else 'execution_error' + return tool_failure(code, f"Error opening browser: {exc}", retriable=False) @legacy_text_api def snapshot(self, session_id: str) -> str: diff --git a/openkyrozen/tools/filesystem.py b/openkyrozen/tools/filesystem.py index 9e789ad..d314c7a 100644 --- a/openkyrozen/tools/filesystem.py +++ b/openkyrozen/tools/filesystem.py @@ -60,6 +60,8 @@ def list_dir(self, args: str) -> str: abs_path = self._resolve_workspace_path(dir_path) entries = os.listdir(abs_path) return tool_success(str("\n".join(sorted(entries)))) + except FileNotFoundError as e: + return tool_failure('not_found', f"Error listing directory: {e}", retriable=False) except Exception as e: return tool_failure('execution_error', f"Error listing directory: {e}", retriable=False) diff --git a/openkyrozen/tools/git.py b/openkyrozen/tools/git.py index 4ee7dd5..3d2cd93 100644 --- a/openkyrozen/tools/git.py +++ b/openkyrozen/tools/git.py @@ -39,7 +39,7 @@ def git_clone(self, args: str) -> str: return tool_failure('command_failed', f"Git clone failed:\n{result.stderr}", retriable=False) return tool_success(str(f"Repository cloned successfully:\n{result.stdout}")) except Exception as e: - return tool_failure('execution_error', f"Error during git clone: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error during git clone: {e}", retriable=False) @legacy_text_api @@ -62,7 +62,7 @@ def git_diff(self, args: str) -> str: out = result.stdout.strip() return (tool_success(str(out)) if out else tool_success(str("(no changes)"))) except Exception as e: - return tool_failure('execution_error', f"Error running git diff: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git diff: {e}", retriable=False) @legacy_text_api @@ -87,7 +87,7 @@ def git_log(self, args: str) -> str: out = result.stdout.strip() return (tool_success(str(out)) if out else tool_success(str("(no commits)"))) except Exception as e: - return tool_failure('execution_error', f"Error running git log: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git log: {e}", retriable=False) @legacy_text_api @@ -125,7 +125,7 @@ def git_branch(self, args: str) -> str: return tool_success(str(f"Created branch {name!r}.")) return (tool_success(str(out)) if out else tool_success(str("(no branches)"))) except Exception as e: - return tool_failure('execution_error', f"Error running git branch: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git branch: {e}", retriable=False) @legacy_text_api @@ -144,7 +144,7 @@ def git_add(self, args: str) -> str: return tool_failure('command_failed', f"Git add failed:\n{result.stderr}", retriable=False) return tool_success(str("Files staged successfully.")) except Exception as e: - return tool_failure('execution_error', f"Error running git add: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git add: {e}", retriable=False) @legacy_text_api @@ -164,7 +164,7 @@ def git_commit(self, args: str) -> str: return tool_failure('command_failed', f"Git commit failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "Commit successful.")) except Exception as e: - return tool_failure('execution_error', f"Error running git commit: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git commit: {e}", retriable=False) @legacy_text_api @@ -183,7 +183,7 @@ def git_push(self, args: str) -> str: return tool_failure('command_failed', f"Git push failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "Push successful.")) except Exception as e: - return tool_failure('execution_error', f"Error running git push: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git push: {e}", retriable=False) @legacy_text_api @@ -202,7 +202,7 @@ def git_pull(self, args: str) -> str: return tool_failure('command_failed', f"Git pull failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "Already up to date.")) except Exception as e: - return tool_failure('execution_error', f"Error running git pull: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git pull: {e}", retriable=False) @legacy_text_api @@ -224,7 +224,7 @@ def git_checkout(self, args: str) -> str: return tool_failure('command_failed', f"Git checkout failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "Checkout successful.")) except Exception as e: - return tool_failure('execution_error', f"Error running git checkout: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git checkout: {e}", retriable=False) @legacy_text_api @@ -243,7 +243,7 @@ def git_stash(self, args: str) -> str: return tool_failure('command_failed', f"Git stash failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "Working directory clean.")) except Exception as e: - return tool_failure('execution_error', f"Error running git stash: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git stash: {e}", retriable=False) @legacy_text_api @@ -272,7 +272,7 @@ def git_reset(self, args: str) -> str: return tool_failure('command_failed', f"Git reset failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "Reset successful.")) except Exception as e: - return tool_failure('execution_error', f"Error running git reset: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git reset: {e}", retriable=False) @legacy_text_api @@ -289,7 +289,7 @@ def git_show(self, args: str) -> str: return tool_failure('command_failed', f"Git show failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip())) except Exception as e: - return tool_failure('execution_error', f"Error running git show: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git show: {e}", retriable=False) @legacy_text_api @@ -331,7 +331,7 @@ def git_remote(self, args: str) -> str: return tool_success(str(f"Remote '{parts[1]}' removed.")) return tool_success(str("(no remotes configured)")) except Exception as e: - return tool_failure('execution_error', f"Error running git remote: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git remote: {e}", retriable=False) @legacy_text_api @@ -348,7 +348,7 @@ def git_status(self, args: str) -> str: return tool_failure('command_failed', f"Git status failed:\n{result.stderr}", retriable=False) return tool_success(str(result.stdout.strip() or "(clean)")) except Exception as e: - return tool_failure('execution_error', f"Error running git status: {e}", retriable=False) + return tool_failure('timeout' if isinstance(e, subprocess.TimeoutExpired) else 'execution_error', f"Error running git status: {e}", retriable=False) @legacy_text_api diff --git a/openkyrozen/tools/graph.py b/openkyrozen/tools/graph.py index a4ff0ef..31fcea9 100644 --- a/openkyrozen/tools/graph.py +++ b/openkyrozen/tools/graph.py @@ -17,13 +17,19 @@ def graph_status(self, _args: str = "") -> str: @legacy_text_api def graph_query(self, args: str) -> str: """Query the private Graphify index. Args: a project question.""" - return (tool_success(str(self._PROJECT_GRAPH.query(args))) if self._PROJECT_GRAPH is not None else tool_failure('not_found', "Error: project graph is not configured.", retriable=False)) + if self._PROJECT_GRAPH is None: + return tool_failure('not_found', "Error: project graph is not configured.", retriable=False) + result = self._PROJECT_GRAPH.query_result(args) + return tool_success(result.text) if result.success else tool_failure(result.error_code, result.text) @legacy_text_api def graph_explain(self, args: str) -> str: """Explain one graph node. Args: node label.""" - return (tool_success(str(self._PROJECT_GRAPH.explain(args))) if self._PROJECT_GRAPH is not None else tool_failure('not_found', "Error: project graph is not configured.", retriable=False)) + if self._PROJECT_GRAPH is None: + return tool_failure('not_found', "Error: project graph is not configured.", retriable=False) + result = self._PROJECT_GRAPH.explain_result(args) + return tool_success(result.text) if result.success else tool_failure(result.error_code, result.text) @legacy_text_api @@ -34,7 +40,8 @@ def graph_path(self, args: str) -> str: parts = str(args).split("|", 1) if len(parts) != 2: return tool_failure('invalid_arguments', "Error: graph_path requires left|right.", retriable=False) - return tool_success(str(self._PROJECT_GRAPH.path(parts[0], parts[1]))) + result = self._PROJECT_GRAPH.path_result(parts[0], parts[1]) + return tool_success(result.text) if result.success else tool_failure(result.error_code, result.text) @legacy_text_api diff --git a/openkyrozen/tools/manifest.py b/openkyrozen/tools/manifest.py index 2e6f08a..6d73f38 100644 --- a/openkyrozen/tools/manifest.py +++ b/openkyrozen/tools/manifest.py @@ -230,6 +230,13 @@ def __len__(self): return sum(1 for _ in self) +def typed_executor(function): + typed=getattr(getattr(function,"__func__",function),"__tool_result_executor__",None) + if typed is None: + return function + return typed.__get__(function.__self__,type(function.__self__)) if hasattr(function,"__self__") else typed + + class ToolRegistry: def __init__(self, tools=None): self._specs={} @@ -274,9 +281,7 @@ def register_spec(self,spec,*,replace=False): def register(self,name,function,*,capability=None,risk=None,version=None,source=None): from .catalog import builtin_metadata old=self._specs.get(name) - typed=getattr(getattr(function,"__func__",function),"__tool_result_executor__",None) - if typed is not None: - function=typed.__get__(function.__self__,type(function.__self__)) if hasattr(function,"__self__") else typed + function=typed_executor(function) if old: updates={'executor':function} updates.update({k:v for k,v in {'capability':capability,'risk':risk,'version':version,'source':source}.items() if v is not None}) diff --git a/openkyrozen/workspace/graph.py b/openkyrozen/workspace/graph.py index bbac489..b9fcbcc 100644 --- a/openkyrozen/workspace/graph.py +++ b/openkyrozen/workspace/graph.py @@ -8,6 +8,7 @@ import subprocess import sys import threading +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable @@ -40,6 +41,16 @@ def _utc_now() -> str: return datetime.now(timezone.utc).isoformat() +@dataclass(frozen=True) +class GraphQueryResult: + text: str + error_code: str | None = None + + @property + def success(self) -> bool: + return self.error_code is None + + class ProjectGraph: """Keep Graphify output in private state while preserving project-relative paths.""" @@ -288,32 +299,46 @@ def explore(self, *, query: str = "", node_id: str = "", community: int | None = counts = self._graph_counts() return {**state, **counts, "scope": self.cache_root.name, "mini": {"nodes": mini_nodes, "edges": mini_edges}} - def _query_command(self, args: list[str]) -> str: + def _query_command_result(self, args: list[str]) -> GraphQueryResult: with self._lock: if not self.graph_path.is_file(): - return "Error: project graph is not ready. Run /graph refresh." + return GraphQueryResult("Error: project graph is not ready. Run /graph refresh.", "not_found") try: result = self._run_graphify(args, timeout=120) - except (OSError, subprocess.TimeoutExpired) as exc: - return f"Error: Graphify query failed: {exc}" + except subprocess.TimeoutExpired as exc: + return GraphQueryResult(f"Error: Graphify query failed: {exc}", "timeout") + except OSError as exc: + return GraphQueryResult(f"Error: Graphify query failed: {exc}", "execution_error") output = (result.stdout or result.stderr or "").strip() if result.returncode != 0: - return f"Error: Graphify exited with {result.returncode}: {output[-2000:]}" - return self._remap(output)[:MAX_QUERY_CHARS] or "No graph results." + return GraphQueryResult(f"Error: Graphify exited with {result.returncode}: {output[-2000:]}", "execution_error") + return GraphQueryResult(self._remap(output)[:MAX_QUERY_CHARS] or "No graph results.") + + def _query_command(self, args: list[str]) -> str: + return self._query_command_result(args).text def _remap(self, value: str) -> str: return str(value).replace(str(self.mirror_root), str(self.source_root)) def query(self, question: str, *, budget: int = 2500) -> str: + return self.query_result(question, budget=budget).text + + def query_result(self, question: str, *, budget: int = 2500) -> GraphQueryResult: question = str(question).strip()[:2000] if not question: - return "Error: graph_query requires a question." - return self._query_command(["query", question, "--budget", str(max(200, min(int(budget), 5000)))]) + return GraphQueryResult("Error: graph_query requires a question.", "invalid_arguments") + return self._query_command_result(["query", question, "--budget", str(max(200, min(int(budget), 5000)))]) def explain(self, node: str) -> str: + return self.explain_result(node).text + + def explain_result(self, node: str) -> GraphQueryResult: node = str(node).strip()[:300] - return self._query_command(["explain", node]) if node else "Error: graph_explain requires a node." + return self._query_command_result(["explain", node]) if node else GraphQueryResult("Error: graph_explain requires a node.", "invalid_arguments") def path(self, left: str, right: str) -> str: + return self.path_result(left, right).text + + def path_result(self, left: str, right: str) -> GraphQueryResult: left, right = str(left).strip()[:300], str(right).strip()[:300] - return self._query_command(["path", left, right]) if left and right else "Error: graph_path requires left|right." + return self._query_command_result(["path", left, right]) if left and right else GraphQueryResult("Error: graph_path requires left|right.", "invalid_arguments") diff --git a/tests/test_browser_manager.py b/tests/test_browser_manager.py index 24c4876..37ca989 100644 --- a/tests/test_browser_manager.py +++ b/tests/test_browser_manager.py @@ -53,6 +53,25 @@ def import_without_playwright(name, *args, **kwargs): result = manager.open("https://example.com") self.assertIn("playwright", result.lower()) + def test_missing_chromium_is_dependency_unavailable(self): + import builtins + from types import SimpleNamespace + from openkyrozen.tools.models import invoke_tool_result + with tempfile.TemporaryDirectory() as directory: + manager = BrowserManager(root=directory) + playwright = SimpleNamespace(stop=lambda: None, chromium=SimpleNamespace( + launch_persistent_context=lambda **_kwargs: (_ for _ in ()).throw( + RuntimeError("Executable doesn't exist at /missing/chromium")))) + real_import = builtins.__import__ + def fake_import(name, *args, **kwargs): + if name == "playwright.sync_api": + return SimpleNamespace(sync_playwright=lambda: SimpleNamespace(start=lambda: playwright)) + return real_import(name, *args, **kwargs) + with patch("openkyrozen.tools.browser_manager.socket.gethostbyname", return_value="93.184.216.34"), \ + patch("builtins.__import__", side_effect=fake_import): + result = invoke_tool_result(manager.open, "https://example.com") + self.assertEqual(result.error.code, "dependency_unavailable") + @unittest.skipUnless( os.environ.get("KYROZEN_BROWSER_TESTS") == "1", "browser integration requires `make install` (Playwright and Chromium)", diff --git a/tests/test_project_graph.py b/tests/test_project_graph.py index 6079269..360711e 100644 --- a/tests/test_project_graph.py +++ b/tests/test_project_graph.py @@ -39,6 +39,21 @@ def __call__(self, command, **kwargs): class ProjectGraphTests(unittest.TestCase): + def test_typed_queries_distinguish_validation_index_and_execution_failures(self): + from unittest.mock import patch + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "project" + root.mkdir() + (root / "app.py").write_text("x = 1\n", encoding="utf-8") + graph = ProjectGraph(root, Path(directory) / "state", "scope", runner=FakeGraphify()) + self.assertEqual(graph.query_result("").error_code, "invalid_arguments") + self.assertEqual(graph.query_result("question").error_code, "not_found") + graph.refresh() + with patch.object(graph, "_run_graphify", return_value=subprocess.CompletedProcess([], 1, "", "failed")): + self.assertEqual(graph.query_result("question").error_code, "execution_error") + with patch.object(graph, "_run_graphify", side_effect=subprocess.TimeoutExpired("graphify", 1)): + self.assertEqual(graph.query_result("question").error_code, "timeout") + def test_git_incremental_sync_ignores_files_and_removes_vanished_sources(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "repo" diff --git a/tests/test_task_consistency.py b/tests/test_task_consistency.py index 7e17c38..056a90c 100644 --- a/tests/test_task_consistency.py +++ b/tests/test_task_consistency.py @@ -391,7 +391,9 @@ def counted_write(args: str) -> ToolResult: self.assertEqual({item["payload"]["operation_id"] for item in receipts}, {receipts[0]["payload"]["operation_id"]}) self.assertEqual({item["payload"]["success"] for item in receipts}, {True, False}) - self.assertIn("duplicate_operation", {item["payload"]["failure"] for item in receipts}) + self.assertIn("conflict", {item["payload"]["failure"] for item in receipts}) + duplicate = next(item["payload"] for item in receipts if item["payload"]["failure"] == "conflict") + self.assertEqual(duplicate["tool_error"]["code"], "conflict") self.assertIn("marker is complete", reply.lower()) def test_multi_task_plan_reconciles_receipts_without_taskdone_and_survives_restart(self): diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index dbdc4bb..81468d2 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -25,6 +25,17 @@ def test_compatibility_registration_has_restrictive_metadata(self): self.assertFalse(spec.parallel_safe) self.assertEqual(spec.input_schema['properties'],{'args':{'type':'string'}}) + def test_replacing_legacy_mapping_preserves_typed_builtins(self): + import tempfile + from openkyrozen.security.capabilities import issue_capability_token + with tempfile.TemporaryDirectory() as directory: + adapters = ToolAdapters(directory) + adapters.AVAILABLE_TOOLS = dict(adapters.AVAILABLE_TOOLS) + result = adapters.tool_registry.invoke_result( + 'read_file', 'missing.txt', issue_capability_token('test', {'read'})) + self.assertFalse(result.success) + self.assertEqual(result.error.code, 'not_found') + def test_mcp_provider_schemas_cannot_drift_or_mutate_catalog(self): adapters = ToolAdapters() registry = adapters.tool_registry diff --git a/tests/test_tool_results.py b/tests/test_tool_results.py index e3106eb..e601440 100644 --- a/tests/test_tool_results.py +++ b/tests/test_tool_results.py @@ -1,4 +1,5 @@ import dataclasses +import json import unittest from openkyrozen.tools import models @@ -148,3 +149,66 @@ def test_learning_stats_use_explicit_outcome_boolean(self): self.assertEqual(runtime._tool_stats["read_file"]["successes"], 1) self.assertEqual(runtime._tool_stats["write_file"]["successes"], 0) self.assertEqual([event[1]["success"] for event in events], [True, False]) + + +class ReviewFindingRegressions(unittest.TestCase): + def test_receipt_error_metadata_is_redacted_and_bounded(self): + from types import SimpleNamespace + from openkyrozen.agent.executor import _make_execution_receipt + runtime = SimpleNamespace( + _operation_action=lambda action: action, + _operation_args=lambda _action, args: str(args), + _operation_id=lambda *_args: "operation", + _fix_safe_text=lambda value, limit=500, **_kwargs: str(value)[:limit], + _subagent_tool_evidence=lambda *_args, **_kwargs: (None, False), + _acceptance_for_tool=lambda *_args, **_kwargs: (None, False), + ) + receipt = _make_execution_receipt( + runtime, action="read_file", args="x", authorized=True, started_at="now", success=False, + result="failed", operation_scope="test", + tool_error=ToolError("execution_error", "failed", metadata={ + "api_key": "secret-value", "request_body": "private request", "details": "x" * 10000, + }), + ).as_dict() + metadata = receipt["tool_error"]["metadata"] + self.assertEqual(metadata["api_key"], "") + self.assertEqual(metadata["request_body"], "") + self.assertLessEqual(len(json.dumps(metadata)), 4100) + self.assertNotIn("secret-value", json.dumps(receipt)) + + def test_filesystem_and_git_failures_have_specific_codes(self): + import tempfile + import subprocess + from unittest.mock import patch + from openkyrozen.security.capabilities import issue_capability_token + from openkyrozen.tools import ToolAdapters + with tempfile.TemporaryDirectory() as directory: + tools = ToolAdapters(directory).tool_registry + token = issue_capability_token("test", {"read", "git"}) + missing = tools.invoke_result("list_dir", "missing", token) + self.assertEqual(missing.error.code, "not_found") + with patch("openkyrozen.tools.git.subprocess.run", side_effect=subprocess.TimeoutExpired("git", 60)): + timed_out = tools.invoke_result("git_clone", "https://example.invalid/repo", token) + self.assertEqual(timed_out.error.code, "timeout") + + def test_malformed_delegation_json_is_invalid_arguments(self): + from types import SimpleNamespace + from openkyrozen.tools.models import invoke_tool_result + from openkyrozen.agent.delegation_runtime import spawn_agents + runtime = SimpleNamespace(execution_context=SimpleNamespace(child_run_id="")) + result = invoke_tool_result(spawn_agents, runtime, "{") + self.assertEqual(result.error.code, "invalid_arguments") + + def test_graph_tool_propagates_workspace_failure_status(self): + import tempfile + from pathlib import Path + from openkyrozen.security.capabilities import issue_capability_token + from openkyrozen.tools import ToolAdapters + from openkyrozen.workspace.graph import ProjectGraph + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + tools = ToolAdapters(root) + graph = ProjectGraph(root, root / "state", "scope", runner=lambda *_args, **_kwargs: None) + tools._PROJECT_GRAPH = graph + result = tools.tool_registry.invoke_result("graph_query", "question", issue_capability_token("test", {"read"})) + self.assertEqual(result.error.code, "not_found")