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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/tool-output-artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ later tool round is sent to the provider, older native replies become artifact r
while their call/reply identities remain intact. The tool inventory lists the
retrieval schema and bounds. Access still requires permission to use
`read_tool_artifact`.

Legacy text-protocol turns also replace earlier rounds with scoped artifact
references. Their reference includes an `Action` example whose `args` is a JSON
string containing `artifact_id`, `offset`, and `limit`. Archived rounds contain
structured results, including content beyond the legacy display limit. The newest
round stays inline, and a storage failure stops the turn before another model request.
56 changes: 55 additions & 1 deletion openkyrozen/agent/action_rounds.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
import uuid
from typing import Any
from openkyrozen.tasks.engine import canonical_status, is_complete, is_terminal
Expand All @@ -8,14 +9,23 @@
def _execute_action_rounds(self, turn):
from openkyrozen.agent.delegation import GUIDANCE
results: list[str] = []
full_results: list[dict[str, Any]] = []
turn.tool_records: list[dict[str, Any]] = []
successful_operations: set[str] = set()
operation_scope = str(turn.learning_run.get("run_id") or uuid.uuid4().hex)

def full_result(receipt, prompt_result):
from openkyrozen.agent.conversation import tool_result_to_dict
result = getattr(receipt, "tool_result", None)
return {"action": receipt.action, "arguments": receipt.args,
"output": tool_result_to_dict(result) if result is not None else {"text": prompt_result}}

def record_initial_call(tc, receipt):
turn.tool_records.append(self._record_turn_receipt(receipt, allow_legacy_task_matching=True))
prompt_result = self._tool_result_for_prompt(receipt)
self._render_tool_result(receipt.action, prompt_result)
results.append(f"- `{receipt.action}({receipt.args!r})` returned:\n{self._safe_fstring(prompt_result)}")
full_results.append(full_result(receipt, prompt_result))
if self.tasks.tasks:
self._update_tasks_panel()
self._execute_tool_batch(
Expand All @@ -41,6 +51,20 @@ def record_initial_call(tc, receipt):
evolving_messages = None
previous_model_reply = None
previous_tool_results: list[str] = []
previous_full_results: list[dict[str, Any]] = []
previous_result_slot = None

def archive_result(slot):
if slot is None:
return
message, start, output, complete = slot
content = json.dumps({"results": complete}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
artifact_id = self._agent_engine._store_tool_output(content)
arguments = json.dumps({"artifact_id": artifact_id, "offset": 0, "limit": 1000})
action = json.dumps({"action": "read_tool_artifact", "args": arguments})
reference = f"Earlier tool results archived. Retrieve a page with Action: {action}"
message["content"] = message["content"][:start] + reference + message["content"][start + len(output):]

distribution_reply = None
coordinator = self.subagent_manager.coordinator
delegated_this_turn = coordinator and any(run["version"] != turn.agent_versions.get(run["run_id"])
Expand Down Expand Up @@ -205,12 +229,33 @@ def record_initial_call(tc, receipt):
summary_messages.insert(2, {"role": "system", "content": memory})
if evolving_messages is not None:
summary_messages = evolving_messages
try:
archive_result(previous_result_slot)
except Exception:
turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run."
break
summary_messages.append({"role": "assistant", "content": previous_model_reply or ""})
previous_result_slot = None
if previous_tool_results:
summary_messages.append({"role": "user", "content": "Tool results:\n" + "\n".join(previous_tool_results)})
output = "\n".join(previous_tool_results)
message = {"role": "user", "content": "Tool results:\n" + output}
summary_messages.append(message)
previous_result_slot = (message, len("Tool results:\n"), output, previous_full_results)
if error_hint or search_throttle:
summary_messages.append({"role": "user", "content": error_hint + search_throttle
+ self._build_task_progress_hint()})
elif turn.tool_results_text:
message = summary_messages[-1]
start = -1
for label in ("The tools returned:\n", "Read-only inspection results:\n", "Tool results:\n"):
position = message["content"].rfind(label + turn.tool_results_text)
if position >= 0:
start = position + len(label)
break
if start < 0:
turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run."
break
previous_result_slot = (message, start, turn.tool_results_text, full_results)
if distribution_reply is not None:
step_reply, distribution_reply = distribution_reply, None
else:
Expand Down Expand Up @@ -375,13 +420,15 @@ def record_initial_call(tc, receipt):
# execute all new tool calls
round_count += 1
next_results: list[str] = []
next_full_results: list[dict[str, Any]] = []
has_errors = False
def record_followup_call(tc2, receipt):
nonlocal has_errors, consecutive_search_failures, total_search_calls
turn.tool_records.append(self._record_turn_receipt(receipt, allow_legacy_task_matching=True))
prompt_result = self._tool_result_for_prompt(receipt)
self._render_tool_result(receipt.action, prompt_result)
next_results.append(f"- `{receipt.action}({receipt.args!r})` returned:\n{self._safe_fstring(prompt_result)}")
next_full_results.append(full_result(receipt, prompt_result))
if self.tasks.tasks:
self._update_tasks_panel()
if not receipt.success:
Expand All @@ -408,8 +455,14 @@ def record_followup_call(tc2, receipt):
evolving_messages = summary_messages
previous_model_reply = step_reply
previous_tool_results = list(next_results)
previous_full_results = list(next_full_results)
# Hard limit: too many search_web calls → one last chance to complete
if total_search_calls >= 7 and consecutive_search_failures >= 3:
try:
archive_result(previous_result_slot)
except Exception:
turn.protocol_error_message = "Could not retain earlier tool results; no further tools were run."
break
final_msg = (
"System: Search is unavailable (7+ calls, {0} consecutive failures). "
"Stop searching now. Give your **best final answer** using whatever "
Expand All @@ -419,6 +472,7 @@ def record_followup_call(tc2, receipt):
).format(consecutive_search_failures)
step_reply = self._call_llm_with_spinner(
summary_messages + [{"role": "assistant", "content": step_reply},
{"role": "user", "content": "Tool results:\n" + "\n".join(next_results)},
{"role": "user", "content": final_msg}]
).strip()
step_reply, search_meta = self._observe_turn_response(turn, step_reply, summary_messages)
Expand Down
19 changes: 10 additions & 9 deletions openkyrozen/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,23 @@ def _native_tool_names(self) -> set[str]:
discovered = getattr(runtime.execution_context, "discovered_tools", frozenset())
return permitted & (_COMPACT_CORE_TOOLS | set(discovered))

def _archive_replies(self, messages, indexes):
if not indexes:
return
import json
def _store_tool_output(self, content):
runtime = self.runtime
memory = runtime.memory_bank
task_owner = getattr(runtime.current_session, "_delegation_parent", None) or runtime
tasks = getattr(task_owner, "tasks", None)
return memory.store.artifacts.get_or_create(
content, user_id=getattr(tasks, "user_id", None) or memory.user_id,
workspace_id=getattr(tasks, "workspace_id", None) or memory.workspace_id,
session_id=getattr(tasks, "session_id", None) or memory.session_id,
)

def _archive_replies(self, messages, indexes):
import json
for index in indexes:
reply = messages[index]
content = json.dumps(reply.output, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
artifact_id = memory.store.artifacts.get_or_create(
content, user_id=getattr(tasks, "user_id", None) or memory.user_id,
workspace_id=getattr(tasks, "workspace_id", None) or memory.workspace_id,
session_id=getattr(tasks, "session_id", None) or memory.session_id,
)
artifact_id = self._store_tool_output(content)
summary = {"artifact_id": artifact_id, "total_chars": len(content)}
messages[index] = ToolReply(reply.call_id, reply.name, {
"data": summary, "text": "Earlier tool result archived; use read_tool_artifact to retrieve it.",
Expand Down
5 changes: 5 additions & 0 deletions openkyrozen/agent/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,11 @@ def _stop_process(self, args):
def _read_tool_artifact(self, args):
"""Read a session-owned tool artifact in pages up to 1,000 characters."""
from openkyrozen.tools.models import ToolResult, tool_failure
if isinstance(args, str):
try:
args = json.loads(args)
except (TypeError, ValueError):
args = None
artifact_id = args.get("artifact_id") if isinstance(args, dict) else None
offset = args.get("offset", 0) if isinstance(args, dict) else 0
limit = args.get("limit", 1000) if isinstance(args, dict) else 1000
Expand Down
Loading
Loading