refactor(agent)!: unify lossless streaming and collected replies (#713)

* refactor(agent)!: unify lossless streaming and collected replies

* fix(agent): complete reply consumer migration

* fix(agent): migrate Home consumers to canonical replies
This commit is contained in:
jinliyl 2026-11-05 16:04:27 +08:00 • committed by GitHub
parent 70877ce85b
commit 2a307f80a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 3026 additions and 1041 deletions

View file

@ -265,15 +265,14 @@ def collect_usage(target: list | None = None, receipt: Path | None = None):
def observe_usage(app) -> None:
"""Observe owned clients and distinguish native interrupted replies from answers."""
"""Observe owned clients and reject incomplete agent replies."""
for wrapper in app.context.components.get("agent_wrapper", {}).values():
original_reply = wrapper.reply
async def reply(*args, _reply=original_reply, **kwargs):
result = await _reply(*args, **kwargs)
last = result.get("last_message") or {}
if last.get("finished_reason") == "interrupted" or last.get("error") is not None:
raise RuntimeError("Native Agent was interrupted; see the application log.")
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Native Agent reply ended: {result['status']}")
return result
wrapper.reply = reply

View file

@ -291,15 +291,14 @@ def observe_usage(app) -> None:
def observe_completion(app) -> None:
"""Keep native interrupted replies unscored; successful replies are unchanged."""
"""Keep incomplete native replies unscored; completed replies are unchanged."""
for wrapper in app.context.components.get("agent_wrapper", {}).values():
original = wrapper.reply
async def reply(inputs, *args, _reply=original, **kwargs):
result = await _reply(inputs, *args, **kwargs)
last = result.get("last_message") or {}
if str(last.get("finished_reason")) == "interrupted" or last.get("error") is not None:
raise RuntimeError("Native Agent was interrupted; its state remains saved.")
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Native Agent reply ended: {result['status']}")
return result
wrapper.reply = reply

View file

@ -787,8 +787,10 @@ class ReMeBridge:
result = await self.agent_wrapper.reply(user_message, **reply_kwargs)
reply_text = result.get("result", "")
session_id = result.get("session_id", "")
from reme.steps.evolve._evolve import agent_reply_result_text
reply_text = agent_reply_result_text(result)
session_id = result["session_id"]
if session_id:
self.agent_session_id = session_id

View file

@ -557,6 +557,29 @@ Current default components in `reme/config/default.yaml`:
| `keyword_index` | `default` | `bm25` | BM25 keyword index. |
| `file_store` | `default` | `local` | Combines file_graph and keyword_index; defaults to `embedding_store: ""`. |
All three agent backends execute through `reply_stream()`. `reply()` collects that same stream into an
[`AgentReply`](../../reme/schema/agent_reply.py) dictionary: `session_id`, final text in `result`,
`structured_output`, invocation-scoped `usage`, `status`, `error`, `raw`, and the complete ordered JSON `events`.
Both modes accept `output_schema`. Only `completed` is successful; `failed`, `interrupted`, and `waiting` preserve
incomplete outcomes. Setup/transport errors and caller cancellation propagate. Both modes record reported token
usage once per invocation; unavailable usage remains null.
[`StreamChunk`](../../reme/schema/stream_chunk.py) uses `start`, append-only `delta`, and `end` operations.
Non-null start/end payloads replace a block's accumulated content; null leaves it unchanged, and an empty string
clears it. Session, turn, message, block, and tool-call IDs correlate updates. `metadata.native` retains complete
provider events, including unknown fields and diagnostic notifications. `reply_end.chunk` carries the outcome
fields above, excluding `events`; `reply_start`/`reply_end` delimit one invocation, while DONE only closes transport.
`collect_reply(stream)` and `replay_reply(reply)` convert between the async stream and JSON result without losing
payloads, order, native metadata, or terminal state. Collection fails if the terminal outcome is missing or repeated.
`raw` preserves native results: AgentScope's final message in `raw.message` plus an optional
`raw.structured_response`, Claude's `ResultMessage`, or Codex's `TurnResult`. Persist the full AgentReply for exact
replay; native session history supplies item snapshots rather than the original stream's deltas.
Studio reconciles block snapshots, displays structured output and terminal states, and retains live events through
history refreshes. Raw HTTP `stream_chunks()` includes errors and DONE; CLI/text helpers handle errors separately.
Migration: replace `last_message`/`turn` with `result`/`raw`, and consume the terminal outcome instead of concatenating
all stream payloads. Existing text-only producers use the default `delta` operation.
Codex wrappers reference `app_server: default` or `app_server: oauth`. Multiple wrappers can share one server while
keeping their own models, instructions and conversations. Omit `session_id` to start a new thread; pass a returned
`session_id` to continue it. Different threads can execute concurrently; operations on one thread are serialized.

View file

@ -538,6 +538,26 @@ flowchart LR
| `keyword_index` | `default` | `bm25` | BM25 关键词索引 |
| `file_store` | `default` | `local` | 组合 file_graph、keyword_index;默认 `embedding_store: ""` |
三个 agent 后端统一通过 `reply_stream()` 执行,`reply()` 收集同一条流,返回
[`AgentReply`](../../reme/schema/agent_reply.py) 字典:`session_id`、最终文本 `result`、`structured_output`、
本次调用的 `usage`、`status`、`error`、`raw` 和完整、有序的 JSON `events`。两种模式均支持 `output_schema`。
只有 `completed` 表示成功;`failed`、`interrupted` 和 `waiting` 保留未完成的结果。启动、传输异常和调用方的
取消继续传播。两种模式均按调用记录一次已报告的 token 用量;无法获取用量时为 null。
[`StreamChunk`](../../reme/schema/stream_chunk.py) 使用 `start`、追加内容的 `delta` 和 `end` 操作。
start/end 的非 null 内容替换对应块的累计内容;null 表示不变,空字符串表示清空。会话、调用、消息、内容块和
工具调用 ID 用于关联更新。`metadata.native` 保留完整原生事件,包括未知字段和诊断通知。
`reply_end.chunk` 携带上述结果字段,不含 `events`;`reply_start`/`reply_end` 包含一次 agent 调用内的模型与工具
循环,DONE 仅关闭传输。`collect_reply(stream)` 与 `replay_reply(reply)` 在异步流和 JSON 结果间无损转换,保留
payload、顺序、原生元数据和终止状态;缺少或重复的终止结果会明确报错。
`raw` 保留原生结果:AgentScope 的最终消息在 `raw.message`,额外结构化结果在 `raw.structured_response`;
Claude 保留 `ResultMessage`,Codex 保留 `TurnResult`。需要原样重放时应保存完整 AgentReply;原生会话历史提供
item 快照,不含原始流的逐次增量。Studio 合并内容快照,展示结构化输出和终止状态,历史刷新保留已有实时事件。
HTTP `stream_chunks()` 完整返回错误事件和 DONE,CLI 与文本辅助函数单独处理报错。
迁移:用 `result`/`raw` 替代 `last_message`/`turn`,消费终止结果,不再拼接所有流式 payload。
纯文本生产者使用默认 `delta` 操作。
Codex wrapper 通过 `app_server: default` 或 `app_server: oauth` 引用共享组件。多个 wrapper 可以共享同一个进程,
并分别配置模型、指令和会话。不传 `session_id` 时创建新 thread;传回已有 `session_id` 时续聊。
不同 thread 可以并发,同一 thread 的操作串行。

View file

@ -325,10 +325,9 @@ class AutoFinStep(BaseStep):
if tool_context_id:
kwargs["tool_context_id"] = tool_context_id
result = await self.agent_wrapper.reply(prompt, **kwargs)
if not isinstance(result, dict) or result.get("structured_output") is None:
raise ValueError(f"Auto Fin Agent returned no structured output: {self._preview(result)}")
value = result["structured_output"]
output = value if isinstance(value, model) else model.model_validate(value)
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Agent reply ended: {result['status']}")
output = model.model_validate(result["structured_output"])
rendered = self._preview(output.model_dump(), AGENT_OUTPUT_LOG_LIMIT)
self.logger.info(
f"[{self.name}] agent output prompt={prompt_name} schema={model.__name__} "

View file

@ -7,6 +7,8 @@ import json
import re
from time import perf_counter
from reme.steps.evolve._evolve import agent_reply_result_text
from .base import AGENT_INPUT_LOG_LIMIT, AGENT_OUTPUT_LOG_LIMIT, AutoFinStep
@ -43,7 +45,7 @@ class AutoFinTopicStep(AutoFinStep):
started_at = perf_counter()
result = await self.agent_wrapper.reply(prompt)
try:
ids = self._parse_news_ids(result.get("result") if isinstance(result, dict) else None, topics)
ids = self._parse_news_ids(agent_reply_result_text(result), topics)
except ValueError as exc:
if attempt:
raise ValueError(f"Auto Fin topic Agent returned invalid news IDs: {exc}") from exc

View file

@ -149,7 +149,10 @@ class _TopicAgent(BaseAgentWrapper):
async def reply(self, inputs, **kwargs):
self.calls.append((str(inputs), kwargs))
return {"result": f"筛选结果:\n```json\n{json.dumps(self.selected)}\n```\n以上是相关 ID。"}
return {
"status": "completed",
"result": f"筛选结果:\n```json\n{json.dumps(self.selected)}\n```\n以上是相关 ID。",
}
@pytest.mark.asyncio
@ -221,7 +224,10 @@ async def test_topic_step_retries_invalid_json_once(tmp_path: Path):
async def reply(self, inputs, **kwargs):
self.calls.append((str(inputs), kwargs))
return {"result": ('{"new_ids": "[\\"1\\"]"}' if len(self.calls) == 1 else '```json\n{"黄金": ["1"]}\n```')}
return {
"status": "completed",
"result": ('{"new_ids": "[\\"1\\"]"}' if len(self.calls) == 1 else '```json\n{"黄金": ["1"]}\n```'),
}
app_context = ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai")
agent = RetryAgent({"黄金": []}, app_context=app_context)
@ -293,6 +299,7 @@ class _ReportAgent(BaseAgentWrapper):
topic = re.search(r"当前主题:(\S+)", prompt)
title = f"{topic.group(1)}观察" if topic else "主题新闻观察"
return {
"status": "completed",
"structured_output": AutoFinReportOutput(
title=f"# {title}",
description="关注政策变化。",
@ -363,6 +370,7 @@ async def test_research_names_the_note_after_the_topic_not_the_agent_title(tmp_p
class _VerboseAgent(BaseAgentWrapper):
async def reply(self, _prompt, **_kwargs):
return {
"status": "completed",
"structured_output": AutoFinReportOutput(
title="机器人主题 9-18 研究:" + "量产与政策" * 60,
description="关注政策变化。",

View file

@ -25,6 +25,7 @@ import numpy as np
from json_repair import repair_json
from reme.steps.base_step import BaseStep, Ref
from reme.steps.evolve._evolve import agent_reply_result_text
from reme.components.as_embedding import BaseAsEmbedding
from reme.enumeration import ComponentEnum
@ -77,7 +78,7 @@ async def _llm_equivalence(agent_wrapper, reference: str, system: str) -> bool:
user_prompt = f"First snippet: {reference}\n\nSecond snippet: {system}"
result = await agent_wrapper.reply(user_prompt, system_prompt=system_prompt)
raw = (result.get("result") or "").strip().lower()
raw = agent_reply_result_text(result).lower()
return "yes" in raw
@ -277,7 +278,7 @@ class BeamRubricJudgeStep(BaseStep):
prompt = judge_template.replace("<rubric_item>", item).replace("<llm_response>", llm_response)
result = await self.agent_wrapper.reply(prompt)
raw = (result.get("result") or "").strip()
raw = agent_reply_result_text(result)
try:
parsed = _parse_json_response(raw)

View file

@ -2,7 +2,7 @@
import os
from reme.enumeration import ChunkEnum
from reme.schema import collect_reply
from reme.steps.base_step import BaseStep
from reme.steps.index._dedup import _ToolContextDedupMixin
from reme.utils.counter import global_counter_inc
@ -61,16 +61,24 @@ class BeamAgenticAnswerStep(BaseStep):
if injected_job_kwargs := self._injected_job_kwargs(f"{query}(query time: {query_time})"):
wrapper_kwargs["injected_job_kwargs"] = injected_job_kwargs
if self.context.stream:
text = await self._stream_reply(query, **wrapper_kwargs)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
text = (result.get("result") or "").strip()
try:
if self.context.stream:
result = await collect_reply(
self.agent_wrapper.reply_stream(query, **wrapper_kwargs),
on_chunk=self.context.add_stream_chunk,
)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
finally:
if self.app_context is not None:
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
text = result["result"].strip()
self.context.response.metadata.update(result)
self.logger.debug(f"[{self.name}] response: {text!r}")
self.context.response.success = True
self.context.response.answer = text
self.context.response.success = result["status"] == "completed" and self.context.stream_error is None
self.context.response.answer = result["error"] or text
self.context.response.metadata.update(
{
"query": query,
@ -79,23 +87,4 @@ class BeamAgenticAnswerStep(BaseStep):
"response": text,
},
)
if self.app_context is not None:
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
return self.context.response
async def _stream_reply(self, query: str, **wrapper_kwargs) -> str:
"""Stream unified chunks to the context stream queue."""
assert self.context is not None
text_parts: list[str] = []
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
await self.context.add_stream_string(chunk.chunk, chunk.chunk_type)
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
text_parts.append(chunk.chunk)
if chunk.session_id:
self.context.response.metadata["session_id"] = chunk.session_id
return "".join(text_parts).strip()

View file

@ -30,8 +30,9 @@ _OutputT = TypeVar("_OutputT", bound=BaseModel)
def structured_output(result: dict[str, Any], model: type[_OutputT]) -> _OutputT:
"""Validate an agent wrapper's structured output."""
value = result.get("structured_output")
return value if isinstance(value, model) else model.model_validate(value)
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Agent reply ended: {result['status']}")
return model.model_validate(result["structured_output"])
def strip_frontmatter(body: str) -> str:

View file

@ -82,7 +82,12 @@ class _QueuedAgentWrapper(BaseAgentWrapper):
async def reply(self, inputs, **kwargs) -> dict:
"""Record the request and pop the next structured fixture."""
self.calls.append({"inputs": inputs, "kwargs": kwargs})
return {"structured_output": self.outputs.pop(0), "result": "ok"}
return self._reply_result(
session_id="session",
result="ok",
raw={},
structured_output=self.outputs.pop(0),
)
def _paper(arxiv_id: str, *, title: str = "Paper", upvotes: int = 10) -> PaperInfo:

View file

@ -9,6 +9,7 @@ from urllib.parse import quote_plus
from reme.components.agent_wrapper import handle_session_command
from reme.steps.base_step import BaseStep
from reme.steps.evolve._evolve import agent_reply_result_text
def _session_key(message: Any) -> str:
@ -151,17 +152,11 @@ class DingTalkWaitStep(BaseStep):
builtin_tools=self.builtin_tools,
job_tools=self.job_tools,
)
if not isinstance(result, dict):
raise TypeError("Agent reply must be a dictionary")
if session_id := result.get("session_id"):
if session_id := result["session_id"]:
sessions[key] = session_id
last_message = result.get("last_message")
if isinstance(last_message, dict) and last_message.get("is_error"):
raise RuntimeError("Agent 执行失败")
answer = result.get("result")
if not isinstance(answer, str) or not (answer := answer.strip()):
answer = agent_reply_result_text(result)
if not answer:
raise ValueError("Agent 返回了空回复")
response = await asyncio.to_thread(handler.reply_markdown, "ReMe Agent", answer, message)
if response is None:

View file

@ -155,7 +155,8 @@ class _AgentWrapper(BaseAgentWrapper):
self.reply_calls = []
self.compact_calls = []
self.result_text = "回答"
self.is_error = False
self.status = "completed"
self.error = None
async def compact_session(self, session_id):
self.compact_calls.append(session_id)
@ -163,11 +164,13 @@ class _AgentWrapper(BaseAgentWrapper):
async def reply(self, inputs, **kwargs):
self.reply_calls.append((inputs, kwargs))
session_id = kwargs.get("resume") or "session-1"
return {
"session_id": session_id,
"last_message": {"is_error": self.is_error},
"result": self.result_text,
}
return self._reply_result(
session_id=session_id,
result=self.result_text,
raw={},
status=self.status,
error=self.error,
)
class _Handler:
@ -403,3 +406,23 @@ async def test_stream_client_reconnects_after_server_request(monkeypatch, tmp_pa
step.logger.info.assert_called_once_with(
"[DingTalkWaitStep] DingTalk server requested reconnect reason='connection is expired'; reconnecting in 1.0s",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
@pytest.mark.parametrize("error", [None, "provider failed"])
async def test_incomplete_reply_is_not_sent_as_a_success(tmp_path, status, error):
"""Partial text cannot turn a failed/interrupted/waiting AgentReply into a Markdown reply."""
app_context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _AgentWrapper(app_context=app_context)
wrapper.status, wrapper.error = status, error
wrapper.result_text = "partial answer"
step = DingTalkWaitStep(app_context=app_context, agent_wrapper=wrapper)
step.logger = MagicMock()
handler, sessions, message = _Handler(), {}, _message()
key = _session_key(message)
with pytest.raises(RuntimeError, match=error or status):
await step._handle_message(message, key, sessions, handler)
assert not handler.markdown_replies
assert sessions[key] == "session-1"
assert not any("completed DingTalk reply" in call.args[0] for call in step.logger.info.call_args_list)

View file

@ -643,9 +643,12 @@ and `LLM_BASE_URL` without changing the evaluation judge's model. Override
startup, token accounting and saved session transcripts. The response answer is
`{"results": [...]}`; each item contains `case_id`, `success`, and either `report`
with its session ID/usage or `error`. Metadata includes `evaluation_path`, the
evaluated commit and total `usage` (null if any case fails or usage is unavailable).
evaluated commit and total `usage` (null if any usage is unavailable).
Response success is false if any case fails; completed sibling reports remain
available. No case scratch directory or intermediate analysis files are created.
Returned reports include the agent's `status` and `error` in single-case metadata
or each batch result. Only `completed` succeeds; interrupted, waiting and
iteration-exhausted replies retain their report text and reported usage with `success=false`.
The host bounds each returned report using `output_token_limit` (default 2,000)
with a 10% allowance, rounded down. The agent sees only the configured budget.

View file

@ -35,7 +35,15 @@ CASE_TOOLS = (
"home_compare_memory",
)
CODE_TOOLS = ("home_list_code_tree", "home_search_code", "home_read_code_file")
REPORT_METADATA = ("session_id", "usage", "estimated_tokens", "original_estimated_tokens", "truncated")
REPORT_METADATA = (
"session_id",
"usage",
"status",
"error",
"estimated_tokens",
"original_estimated_tokens",
"truncated",
)
_SANDBOX_PROBE = """\
import json
import pathlib
@ -341,6 +349,7 @@ class HomeCaseStudyStep(BaseStep):
# Preserve the original text response and error handling for singular callers.
if "case_id" in self.context:
result = await self._investigate(scopes[0], spec.focus, prompts, output_limit)
response.success = result["status"] == "completed" and result["error"] is None
response.answer = result["result"]
response.metadata.update(validation_path=scopes[0]["validation_path"], case_id=spec.case_ids[0])
response.metadata.update({key: result[key] for key in REPORT_METADATA if key in result})
@ -353,7 +362,7 @@ class HomeCaseStudyStep(BaseStep):
result = await self._investigate(scope, spec.focus, prompts, output_limit)
return {
"case_id": scope["case_id"],
"success": True,
"success": result["status"] == "completed" and result["error"] is None,
"report": result["result"],
**{key: result[key] for key in REPORT_METADATA if key in result},
}

View file

@ -5,9 +5,12 @@ from __future__ import annotations
import json
import logging
import re
from collections.abc import AsyncGenerator
from contextlib import aclosing
from typing import Any, TypeVar
from agentscope.message import AssistantMsg, Msg, Usage, UserMsg
from agentscope.types import ReplyFinishedReason
from pydantic import BaseModel
from .bounded import BoundedReActAgent
@ -24,9 +27,16 @@ class StructuredReportAgent(BoundedReActAgent):
super().__init__(*args, **kwargs)
self.report_model = report_model
async def reply(self, *args, **kwargs) -> Msg:
async def _reply(self, *args, **kwargs) -> AsyncGenerator[Any, None]:
"""Recover final reports through both native reply entry points."""
async with aclosing(super()._reply(*args, **kwargs)) as stream:
async for item in stream:
if isinstance(item, Msg) and item.finished_reason not in {None, ReplyFinishedReason.INTERRUPTED}:
item = await self._finalize_report(item)
yield item
async def _finalize_report(self, reply: Msg) -> Msg:
"""Keep normal wrapper persistence and accounting around report recovery."""
reply = await super().reply(*args, **kwargs)
try:
report = self.report_model.model_validate(parse_json_object(reply, report_name=self.name))
except ValueError as exc:
@ -57,7 +67,17 @@ class StructuredReportAgent(BoundedReActAgent):
usage = Usage(
**{key: getattr(reply.usage, key) + getattr(corrected.usage, key, 0) for key in Usage.model_fields},
)
reply = AssistantMsg(name=self.name, content=report.model_dump_json(), usage=usage)
reply = reply.model_copy(
update={
"content": AssistantMsg(name=self.name, content=report.model_dump_json()).content,
"usage": usage,
"finished_reason": (
ReplyFinishedReason.INTERRUPTED
if corrected.finished_reason == "interrupted"
else reply.finished_reason
),
},
)
self.state.context.extend([correction, reply])
return reply
return reply.model_copy(

View file

@ -8,6 +8,7 @@ from uuid import uuid4
from agentscope.tool import BackendBase, DirEntry, Glob, Grep, Read, Toolkit, Write
import yaml
from reme.steps.evolve._evolve import agent_reply_result_text
from reme.utils.async_utils import complete_in_thread as blocking
from ..auto_bash import AutoBash
@ -96,7 +97,7 @@ async def workflow_reply(
if not probe.ok():
raise RuntimeError("Home workflow sandbox unavailable: " + probe.stderr.decode(errors="replace"))
prompt = yaml.safe_load(Path(__file__).with_name(f"{role}.yaml").read_text(encoding="utf-8"))
return await wrapper.reply(
reply = await wrapper.reply(
task,
system_prompt=prompt["system_prompt"].format(**scope),
toolkit=create_toolkit(
@ -116,6 +117,8 @@ async def workflow_reply(
permission_mode="bypass",
**(agent_options or {}),
)
agent_reply_result_text(reply)
return reply
def prepare_scope(home, role):
@ -204,7 +207,11 @@ async def review_report(
permission_mode="bypass",
)
report.update({key: reply.get(key) for key in ("session_id", "usage", "result")})
report.update(report_model.model_validate(parse_json_object(reply["result"], report_name=role)).model_dump())
report.update(
report_model.model_validate(
parse_json_object(agent_reply_result_text(reply), report_name=role),
).model_dump(),
)
except Exception as exc:
report.update(
decision=failure_decision,

View file

@ -247,9 +247,11 @@ Call `home_case_study(evaluation_path, case_ids, focus=...)` for up to eight
unique cases sharing one focus and evaluation. Cases run sequentially and results
preserve input order. Each gets fresh agent state, bounded iterations and advisory
time reminders. The answer contains `results`, with a report/session/usage or error
for each case; a failed case does not cancel the rest. Response success is false
if any case fails. Metadata includes `evaluation_path`, evaluated commit and total
usage (null if any call fails or usage is unavailable). `validation_path` remains
for each case; a failed case does not cancel the rest. Report results also include
the agent's `status` and `error`; only `completed` succeeds. Incomplete reports
retain their text and reported usage. Response success is false if any case fails.
Metadata includes `evaluation_path`, evaluated commit and total usage (null if any
usage is unavailable). `validation_path` remains
a path alias; legacy `case_id` calls retain a text report. Do not combine aliases.
Other inspection tools still use `validation_path`. There is no parallel case dispatcher. Scope and evaluated code revision are host-injected
into the structured tools. AutoBash and Python share a mandatory read-only file

View file

@ -196,7 +196,8 @@ async def test_multiround_usage_and_tools_continue_past_soft_time_limit(wrapper,
}
result = await wrapper.reply("Investigate", **options)
assert result["usage"] == {"input_tokens": 30, "output_tokens": 15, "total_tokens": 45}
assert result["last_message"]["finished_reason"] == "completed"
assert result["status"] == "completed"
assert result["raw"]["message"]["finished_reason"] == "completed"
assert len(executions) == 2
assert executions[-1] > 60 # A tool really executes after the soft deadline.
assert len(model.calls) == 3

View file

@ -149,7 +149,10 @@ async def test_nearest_ancestor_and_infrastructure_retry(app, prepared, sandbox,
@pytest.mark.asyncio
@pytest.mark.parametrize("output", ["invalid", '{"decision":"reject","reason":"x","extra":true}', "error"])
@pytest.mark.parametrize(
"output",
["invalid", '{"decision":"reject","reason":"x","extra":true}', "error", "failed", "interrupted", "waiting"],
)
async def test_model_failure_allows_validation_with_saved_diagnostics(app, prepared, sandbox, monkeypatch, output):
commit_candidate(prepared)
wrapper = set_model(app, "home_candidate_gate", ScriptedModel(final=output))
@ -159,10 +162,24 @@ async def test_model_failure_allows_validation_with_saved_diagnostics(app, prepa
raise RuntimeError("model unavailable")
monkeypatch.setattr(wrapper, "reply", broken)
elif output in {"failed", "interrupted", "waiting"}:
monkeypatch.setattr(
wrapper,
"reply",
AsyncMock(
return_value={
"result": '{"decision":"reject","reason":"Unfinished judgement"}',
"status": output,
"error": None,
},
),
)
response = await app.run_job("home_evaluate", max_evaluations=10)
assert response.success, response.answer
report = response.answer["candidate_gate"]
assert report["decision"] == "accept" and report["review_failed"]
if output in {"failed", "interrupted", "waiting"}:
assert output in report["reason"]
assert read_json(Path(report["review_path"]))["review_failed"]
assert not list((prepared / "tmp").glob("candidate-gate-*"))
@ -216,7 +233,7 @@ async def test_review_change_race_and_cancellation_release_locks(app, prepared,
async def paused(*args, **kwargs):
entered.set()
await release.wait()
return {"result": '{"decision":"accept","reason":"Mechanism change"}'}
return {"result": '{"decision":"accept","reason":"Mechanism change"}', "status": "completed", "error": None}
monkeypatch.setattr(wrapper, "reply", paused)
task = asyncio.create_task(app.run_job("home_evaluate", max_evaluations=10))

View file

@ -225,7 +225,8 @@ async def test_invalid_scope_and_sandbox_failure_precede_llm(app, prepared, case
@pytest.mark.asyncio
async def test_iteration_limit_returns_final_report(app, prepared, case_run, sandbox):
@pytest.mark.parametrize("batch", [False, True])
async def test_iteration_limit_preserves_report_and_failure(app, prepared, case_run, sandbox, batch):
run, case_id, _query_id, _commit, _source = case_run
wrapper = app.context.components["agent_wrapper"]["home_case_study"]
assert wrapper.kwargs["react_config"]["max_iters"] == 60
@ -233,8 +234,12 @@ async def test_iteration_limit_returns_final_report(app, prepared, case_run, san
wrapper.kwargs["react_config"] = {"max_iters": 1}
model = RecordingModel()
wrapper.as_llm = SimpleNamespace(model=model)
response = await app.run_job("home_case_study", validation_path=str(run), case_id=case_id)
assert response.success and response.answer == "final report"
arguments = {"case_ids": [case_id]} if batch else {"case_id": case_id}
response = await app.run_job("home_case_study", validation_path=str(run), **arguments)
assert not response.success
report = response.answer["results"][0] if batch else {**response.metadata, "report": response.answer}
assert report["report"] == "final report"
assert report["status"] == "failed" and "exceed_max_iters" in report["error"]
assert model.calls[-1]["tools"] == []
@ -572,7 +577,7 @@ async def test_batch_bounds_concurrency_and_preserves_order(app, many_case_run,
completed.append(case_id)
if case_id == case_ids[1]:
raise RuntimeError("one investigator failed")
return {"result": f"report {case_id}"}
return {"result": f"report {case_id}", "status": "completed", "error": None}
finally:
active.remove(case_id)

View file

@ -51,7 +51,13 @@ def real_reviewer(app, prepared, monkeypatch):
wrapper = app.context.components["agent_wrapper"]["home_construction_equivalence"]
monkeypatch.setattr(wrapper, "reply", MethodType(type(wrapper).reply, wrapper))
gate = app.context.components["agent_wrapper"]["home_candidate_gate"]
monkeypatch.setattr(gate, "reply", AsyncMock(return_value={"result": '{"decision":"accept","reason":"Mechanism"}'}))
monkeypatch.setattr(
gate,
"reply",
AsyncMock(
return_value={"result": '{"decision":"accept","reason":"Mechanism"}', "status": "completed", "error": None},
),
)
return wrapper
@ -116,7 +122,7 @@ async def test_large_resources_export_for_both_reviewers(app, prepared, reviewer
async def accept(*args, **kwargs):
inspect_export("candidate-gate")
return {"result": '{"decision":"accept","reason":"Fixture export check"}'}
return {"result": '{"decision":"accept","reason":"Fixture export check"}', "status": "completed", "error": None}
def finish():
inspect_export("construction-equivalence")
@ -375,7 +381,11 @@ async def test_review_checkout_race_and_cancellation_do_not_sample(app, prepared
async def paused(*args, **kwargs):
entered.set()
await release.wait()
return {"result": '{"decision":"construction_unchanged","reason":"Query only"}'}
return {
"result": '{"decision":"construction_unchanged","reason":"Query only"}',
"status": "completed",
"error": None,
}
monkeypatch.setattr(reviewer, "reply", paused)
task = asyncio.create_task(app.run_job("home_evaluate", sampling=True))

View file

@ -189,12 +189,22 @@ async def prepared_home(app, inputs, monkeypatch):
monkeypatch.setattr(evaluation, "DockerReMeSandboxFactory", FakeFactory)
# Run the real judge Job and Step; mock only its model-facing wrapper.
wrapper = app.context.components["agent_wrapper"]["judge"]
monkeypatch.setattr(wrapper, "reply", AsyncMock(return_value={"result": '{"score": 0.5}'}))
monkeypatch.setattr(
wrapper,
"reply",
AsyncMock(return_value={"result": '{"score": 0.5}', "status": "completed", "error": None}),
)
reviewer = app.context.components["agent_wrapper"]["home_construction_equivalence"]
monkeypatch.setattr(
reviewer,
"reply",
AsyncMock(return_value={"result": '{"decision":"construction_changed","reason":"Fixture rebuild"}'}),
AsyncMock(
return_value={
"result": '{"decision":"construction_changed","reason":"Fixture rebuild"}',
"status": "completed",
"error": None,
},
),
)
return Path(app.config.workspace_dir) / "home"
@ -565,7 +575,16 @@ def test_unsafe_archives_are_rejected_without_partial_extraction(tmp_path, kind)
@pytest.mark.parametrize("app", ["lme-judge"], indirect=True)
async def test_lme_evaluation_calls_host_judge_job(app, prepared, monkeypatch):
wrapper = app.context.components["agent_wrapper"]["judge"]
monkeypatch.setattr(wrapper, "reply", AsyncMock(side_effect=[{"result": "maybe"}, {"result": "yes"}]))
monkeypatch.setattr(
wrapper,
"reply",
AsyncMock(
side_effect=[
{"result": "maybe", "status": "completed", "error": None},
{"result": "yes", "status": "completed", "error": None},
],
),
)
response = await app.run_job("home_evaluate")
assert response.success, response.answer
assert response.answer["mean_query_score"] == 1
@ -585,7 +604,7 @@ async def test_lme_evaluation_calls_host_judge_job(app, prepared, monkeypatch):
@pytest.mark.parametrize("app", ["lme-judge"], indirect=True)
async def test_invalid_lme_judge_is_infra_error_after_one_retry(app, prepared):
wrapper = app.context.components["agent_wrapper"]["judge"]
wrapper.reply.return_value = {"result": "maybe"}
wrapper.reply.return_value = {"result": "maybe", "status": "completed", "error": None}
response = await app.run_job("home_evaluate")
assert not response.success
assert response.answer["mean_query_score"] is None
@ -597,7 +616,11 @@ async def test_invalid_lme_judge_is_infra_error_after_one_retry(app, prepared):
@pytest.mark.asyncio
@pytest.mark.parametrize("raw", ["nonsense", '{"score": 2}', '{"score": 0.2}', '{"score": NaN}'])
async def test_invalid_beam_judge_is_infra_error(app, prepared, raw):
app.context.components["agent_wrapper"]["judge"].reply.return_value = {"result": raw}
app.context.components["agent_wrapper"]["judge"].reply.return_value = {
"result": raw,
"status": "completed",
"error": None,
}
response = await app.run_job("home_evaluate", case_ids=["1"])
assert not response.success
assert response.answer["mean_query_score"] is None

View file

@ -245,7 +245,11 @@ async def test_history_catalog_keeps_successful_low_scores_and_deduplicates(app,
manager = history(app)
assert isinstance(manager, HomeEvaluationHistory) and manager.is_started
assert manager.list_results() == []
app.context.components["agent_wrapper"]["judge"].reply.return_value = {"result": '{"score": 0}'}
app.context.components["agent_wrapper"]["judge"].reply.return_value = {
"result": '{"score": 0}',
"status": "completed",
"error": None,
}
response = await app.run_job("home_evaluate")
assert response.success, response.answer
results = manager.list_results()
@ -644,7 +648,7 @@ async def run():
app = Application(**json.loads(sys.argv[2]))
await app.start()
app.context.components["agent_wrapper"]["judge"].reply = AsyncMock(
return_value={"result": '{"score": 0.5}'})
return_value={"result": '{"score": 0.5}', "status": "completed", "error": None})
app.context.components["home.evaluation_history"]["default"].record = lambda run: os._exit(23)
await app.run_job("home_evaluate")
asyncio.run(run())
@ -663,7 +667,7 @@ asyncio.run(run())
await restarted.start()
try:
judge = restarted.context.components["agent_wrapper"]["judge"]
judge.reply = AsyncMock(return_value={"result": '{"score": 0.5}'})
judge.reply = AsyncMock(return_value={"result": '{"score": 0.5}', "status": "completed", "error": None})
manager = history(restarted)
assert len(manager.list_results()) == 1
assert read_state(prepared)["successful_round"] == 1

View file

@ -195,7 +195,7 @@ async def test_sampled_recovery_preserves_cases_queries_and_order(
monkeypatch.setattr(
app.context.components["agent_wrapper"]["judge"],
"reply",
AsyncMock(return_value={"result": '{"score": 0.5}'}),
AsyncMock(return_value={"result": '{"score": 0.5}', "status": "completed", "error": None}),
)
monkeypatch.setattr("reme_home.sampling.time.time_ns", lambda: 1)
monkeypatch.setattr(FakeFactory, "bad_export", True)

View file

@ -159,7 +159,11 @@ async def test_full_evaluation_records_source_provenance_with_fake_docker(
path, _ = write_config(tmp_path, app, spec, commit)
monkeypatch.setattr(evaluation, "DockerReMeSandboxFactory", FakeFactory)
answer = "yes" if spec["benchmark"]["name"] == "longmemeval" else '{"score": 0.5}'
monkeypatch.setattr(AsAgentWrapper, "reply", AsyncMock(return_value={"result": answer}))
monkeypatch.setattr(
AsAgentWrapper,
"reply",
AsyncMock(return_value={"result": answer, "status": "completed", "error": None}),
)
before = snapshot(home)
result = await home_eval.run_evaluation(path)
run = Path(result["evaluation"]["result_dir"])

View file

@ -129,7 +129,11 @@ async def test_baseline_then_adaptive_job_updates_only_selected_cases(app, prepa
assert all(record["ewma_loss"] == 0.5 for record in state(prepared)["cases"].values())
evaluation = app.context.components["home.evaluation"]["default"]
evaluation.sampling_policy = sampling.SamplingPolicy(max_cases=1, max_queries=1, ewma_alpha=0.5)
app.context.components["agent_wrapper"]["judge"].reply.return_value = {"result": '{"score": 1}'}
app.context.components["agent_wrapper"]["judge"].reply.return_value = {
"result": '{"score": 1}',
"status": "completed",
"error": None,
}
before = copy.deepcopy(state(prepared))
commit_candidate(prepared)
response = await app.run_job("home_evaluate", sampling=True)
@ -304,7 +308,11 @@ async def test_sampling_cache_rebuild_and_new_round_after_reopen(app, prepared):
@pytest.mark.asyncio
async def test_fail_fast_started_round_without_summary_is_not_quality_feedback(app, prepared):
app.context.components["agent_wrapper"]["judge"].reply.return_value = {"result": "invalid judge output"}
app.context.components["agent_wrapper"]["judge"].reply.return_value = {
"result": "invalid judge output",
"status": "completed",
"error": None,
}
response = await app.run_job("home_evaluate", sampling=True, fail_fast=True)
assert not response.success
observed = state(prepared)
@ -401,7 +409,7 @@ async def test_query_budget_and_draw_order_reach_actual_execution(app, inputs, m
monkeypatch.setattr(
app.context.components["agent_wrapper"]["judge"],
"reply",
AsyncMock(return_value={"result": '{"score": 0.5}'}),
AsyncMock(return_value={"result": '{"score": 0.5}', "status": "completed", "error": None}),
)
monkeypatch.setattr(sampling.time, "time_ns", lambda: 123456)
component = app.context.components["home.evaluation"]["default"]

View file

@ -4,17 +4,23 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from agentscope.agent import ReActConfig
from agentscope.message import AssistantMsg, UserMsg
from agentscope.model import ChatUsage, StructuredResponse
from agentscope.message import AssistantMsg, Msg, UserMsg
from agentscope.model import ChatUsage, FinishedReason, StructuredResponse
from reme_home.agent import BoundedAgentWrapper, BoundedReActAgent
from reme_home.agent.candidate_gate import CandidateGateReport
from reme_home.agent.structured_report import StructuredReportAgent, parse_json_object
from test_workflows import ScriptedModel
from reme.components.application_context import ApplicationContext
from reme.schema import collect_reply, replay_reply
from reme.utils import global_counter_get
@pytest.mark.parametrize(
"text",
@ -87,11 +93,12 @@ def make_agent(model):
@pytest.mark.asyncio
@pytest.mark.parametrize("missing_usage", [False, True])
@pytest.mark.parametrize("streaming", [False, True])
@pytest.mark.parametrize(
"initial",
["Reject: mechanism unchanged.", '{"decision":"reject","reason":"x","extra":true}'],
)
async def test_one_correction_retains_history_disables_tools_and_accounts_usage(missing_usage, initial):
async def test_one_correction_retains_history_disables_tools_and_accounts_usage(missing_usage, streaming, initial):
model = ScriptedModel(final=initial)
model.extra_body = {"enable_thinking": True, "thinking_budget": 100, "host_option": "retained"}
model.generate_structured_output = AsyncMock(
@ -102,7 +109,14 @@ async def test_one_correction_retains_history_disables_tools_and_accounts_usage(
)
agent = make_agent(model)
agent.state.summary = "Earlier investigation summary."
reply = await agent.reply(UserMsg(name="host", content="Review this candidate."))
inputs = UserMsg(name="host", content="Review this candidate.")
if streaming:
items = [item async for item in agent.reply_stream(inputs, yield_final_msg=True)]
reply = items[-1]
assert isinstance(reply, Msg)
else:
reply = await agent.reply(inputs)
assert reply.finished_reason == "completed"
assert CandidateGateReport.model_validate_json(reply.get_text_content()).decision == "reject"
assert len(model.calls) == 1
model.generate_structured_output.assert_awaited_once()
@ -158,3 +172,50 @@ async def test_correction_is_not_retried_and_cancellation_propagates(failure):
with pytest.raises(expected):
await make_agent(model).reply(UserMsg(name="host", content="Review."))
model.generate_structured_output.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
@pytest.mark.parametrize("interrupted", [False, True])
async def test_wrapper_collects_and_persists_recovered_report(tmp_path, streaming, interrupted):
model = ScriptedModel(final="Reject: mechanism unchanged.")
model.generate_structured_output = AsyncMock(
return_value=StructuredResponse(
content={"decision": "reject", "reason": "Mechanism unchanged"},
usage=ChatUsage(input_tokens=7, output_tokens=11, time=0),
finished_reason=FinishedReason.INTERRUPTED if interrupted else FinishedReason.COMPLETED,
),
)
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = BoundedAgentWrapper(app_context=context, as_llm="", name="review")
wrapper.as_llm = SimpleNamespace(model=model)
options = {"_structured_report_model": CandidateGateReport, "builtin_tools": []}
if streaming:
result = await collect_reply(wrapper.reply_stream("Review this candidate.", **options))
else:
result = await wrapper.reply("Review this candidate.", **options)
assert result["status"] == ("interrupted" if interrupted else "completed")
assert result["error"] is None
assert CandidateGateReport.model_validate_json(result["result"]).decision == "reject"
assert result["raw"]["message"]["finished_reason"] == result["status"]
assert result["usage"] == {"input_tokens": 9, "output_tokens": 14, "total_tokens": 23}
assert global_counter_get(context.metadata, ["__token_counter", "review", "total_tokens"]) == 23
assert await collect_reply(replay_reply(result)) == result
saved = (wrapper.session_path / f"{result['session_id']}.jsonl").read_text()
assert "home_report_recovery" in saved and "Mechanism unchanged" in saved
model.generate_structured_output.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("reason", [None, "interrupted"])
async def test_incomplete_native_reply_does_not_request_report_correction(monkeypatch, reason):
reply = AssistantMsg(name="review", content="partial", finished_reason=reason)
async def incomplete_reply(self, *args, **kwargs):
yield reply
monkeypatch.setattr(BoundedReActAgent, "_reply", incomplete_reply)
model = ScriptedModel()
model.generate_structured_output = AsyncMock()
assert await make_agent(model).reply() is reply
model.generate_structured_output.assert_not_awaited()

View file

@ -84,6 +84,19 @@ async def baseline(app):
return result.answer["result_dir"]
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
async def test_workflow_rejects_incomplete_agent_reports(app, prepared, sandbox, monkeypatch, status):
wrapper = app.context.components["agent_wrapper"]["home_diagnostic"]
monkeypatch.setattr(
wrapper,
"reply",
AsyncMock(return_value={"result": "Unfinished diagnosis", "status": status, "error": None}),
)
with pytest.raises(RuntimeError, match=status):
await workflow_common.workflow_reply(wrapper, prepared, "diagnostic", "Diagnose this failure.")
def final_report(home, branch="init", path=None):
repo = home / "code/repo"
if path is None:

View file

@ -3,6 +3,7 @@
import re
from reme.steps.base_step import BaseStep
from reme.steps.evolve._evolve import agent_reply_result_text
class LmeAnswerJudgeStep(BaseStep):
@ -58,7 +59,7 @@ class LmeAnswerJudgeStep(BaseStep):
system_prompt = self.prompt_format(judge_prompt_key)
for _ in range(2): # One initial attempt and at most one invalid-output retry.
result = await self.agent_wrapper.reply(user_prompt, system_prompt=system_prompt)
raw_answer = (result.get("result") or "").strip()
raw_answer = agent_reply_result_text(result)
answer = self._normalize_judgement(raw_answer)
if answer in {"yes", "no"}:
break

View file

@ -2,7 +2,7 @@
import os
from reme.enumeration import ChunkEnum
from reme.schema import collect_reply
from reme.steps.base_step import BaseStep
from reme.steps.index._dedup import _ToolContextDedupMixin
from reme.utils.counter import global_counter_inc
@ -61,16 +61,24 @@ class LmeAgenticAnswerStep(BaseStep):
if injected_job_kwargs := self._injected_job_kwargs(f"{query}(query time: {query_time})"):
wrapper_kwargs["injected_job_kwargs"] = injected_job_kwargs
if self.context.stream:
text = await self._stream_reply(query, **wrapper_kwargs)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
text = (result.get("result") or "").strip()
try:
if self.context.stream:
result = await collect_reply(
self.agent_wrapper.reply_stream(query, **wrapper_kwargs),
on_chunk=self.context.add_stream_chunk,
)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
finally:
if self.app_context is not None:
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
text = result["result"].strip()
self.context.response.metadata.update(result)
self.logger.debug(f"[{self.name}] response: {text!r}")
self.context.response.success = True
self.context.response.answer = text
self.context.response.success = result["status"] == "completed" and self.context.stream_error is None
self.context.response.answer = result["error"] or text
self.context.response.metadata.update(
{
"query": query,
@ -79,23 +87,4 @@ class LmeAgenticAnswerStep(BaseStep):
"response": text,
},
)
if self.app_context is not None:
self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {}).pop(tool_context_id, None)
return self.context.response
async def _stream_reply(self, query: str, **wrapper_kwargs) -> str:
"""Stream unified chunks to the context stream queue."""
assert self.context is not None
text_parts: list[str] = []
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
await self.context.add_stream_string(chunk.chunk, chunk.chunk_type)
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
text_parts.append(chunk.chunk)
if chunk.session_id:
self.context.response.metadata["session_id"] = chunk.session_id
return "".join(text_parts).strip()

View file

@ -121,11 +121,11 @@ class MemLensAgenticAnswerStep(BaseStep):
resume="",
react_config={"max_iters": max_iters},
)
self.context.response.answer = (result.get("result") or "").strip()
self.context.response.success = result["status"] == "completed"
self.context.response.answer = result["error"] or result["result"].strip()
self.context.response.metadata.update(
usage=result.get("usage"),
agentscope_usage=(result.get("last_message") or {}).get("usage"),
agent_session_id=result.get("session_id"),
**result,
agent_session_id=result["session_id"],
include_source_sessions=sources,
include_images=images,
max_iters=max_iters,

View file

@ -121,11 +121,11 @@ class MobileMemAgenticAnswerStep(BaseStep):
resume="",
react_config={"max_iters": max_iters},
)
self.context.response.answer = (result.get("result") or "").strip()
self.context.response.success = result["status"] == "completed"
self.context.response.answer = result["error"] or result["result"].strip()
self.context.response.metadata.update(
usage=result.get("usage"),
agentscope_usage=(result.get("last_message") or {}).get("usage"),
agent_session_id=result.get("session_id"),
**result,
agent_session_id=result["session_id"],
include_source_sessions=sources,
include_images=images,
max_iters=max_iters,

View file

@ -1,12 +1,12 @@
"""AgentScope backend for the unified agent wrapper."""
import asyncio
import json
import os
import re
import subprocess
import time
from collections.abc import AsyncGenerator
from contextlib import aclosing
from pathlib import Path
from typing import Any, TYPE_CHECKING
from uuid import uuid4
@ -36,7 +36,7 @@ from agentscope.event import (
ToolResultStartEvent,
ToolResultTextDeltaEvent,
)
from agentscope.message import TextBlock, ToolResultState, UserMsg
from agentscope.message import Msg, TextBlock, ToolResultState, UserMsg
from agentscope.permission import PermissionBehavior, PermissionContext, PermissionDecision, PermissionMode
from agentscope.state import AgentState
from agentscope.tool import (
@ -58,7 +58,8 @@ from .base_agent_wrapper import BaseAgentWrapper
from ..as_llm import BaseAsLLM
from ..component_registry import R
from ...enumeration import ChunkEnum
from ...schema import StreamChunk, TokenUsage
from ...schema import AgentReply, StreamChunk, TokenUsage
from ...schema.stream_chunk import stream_status
from ...utils import AsStateHandler
if TYPE_CHECKING:
@ -349,47 +350,64 @@ class AsAgentWrapper(BaseAgentWrapper):
return agent, inputs
async def reply(self, inputs: Any, **kwargs) -> dict:
kwargs = self._merged_kwargs(kwargs)
agent, inputs = await self._build_agent(inputs, **kwargs)
await agent.observe(inputs)
last_msg = await agent.reply()
async def _finish_reply(self, agent: Agent, last_msg: Msg, kwargs: dict) -> AgentReply:
"""Preserve the native message and optional structured-model response."""
usage = self._agentscope_usage(last_msg.usage) if last_msg.usage is not None else None
await self._dump_state(agent.state)
result = {
"session_id": agent.state.session_id,
"last_message": last_msg.model_dump(),
"result": last_msg.get_text_content(),
}
message = last_msg.model_dump(mode="json")
raw = {"message": message}
reason = message["finished_reason"]
status = "completed"
error = None
if reason is None:
status = "waiting"
elif reason == "interrupted":
status = "interrupted"
elif reason != "completed":
status = "failed"
error = (message.get("error") or {}).get("message") or f"AgentScope reply ended: {reason}"
structured_output = message.get("structured_output")
if usage is None:
self.logger.error("AgentScope did not return token usage; token accounting is unavailable for this reply.")
output_schema: dict | None = kwargs.get("output_schema")
if output_schema is not None:
assert self.as_llm is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
model = self.as_llm.model
assert model is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
res = await model.generate_structured_output(
messages=agent.state.context,
structured_model=output_schema,
)
result["structured_output"] = res.content
if res.usage is None:
usage = None
self.logger.error(
"AgentScope did not return structured-output token usage; token accounting is unavailable.",
try:
output_schema: dict | None = kwargs.get("output_schema")
if status == "completed" and output_schema is not None:
assert self.as_llm is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
model = self.as_llm.model
assert model is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
res = await model.generate_structured_output(
messages=agent.state.context,
structured_model=output_schema,
)
elif usage is not None:
usage = TokenUsage.combine([usage, self._agentscope_usage(res.usage)])
raw["structured_response"] = self._serialize(res)
structured_output = res.content
if raw["structured_response"]["finished_reason"] == "interrupted":
status = "interrupted"
if res.usage is None:
usage = None
self.logger.error(
"AgentScope did not return structured-output token usage; token accounting is unavailable.",
)
elif usage is not None:
usage = TokenUsage.combine([usage, self._agentscope_usage(res.usage)])
finally:
if usage is not None:
self._record_token_usage(usage)
result["usage"] = usage.model_dump() if usage is not None else None
if usage is not None:
self._record_token_usage(usage)
return result
task = asyncio.current_task()
if task is not None and task.cancelling():
raise asyncio.CancelledError()
return self._reply_result(
session_id=agent.state.session_id,
result=last_msg.get_text_content(),
structured_output=structured_output,
usage=usage,
status=status,
error=error,
raw=raw,
)
async def compact_session(self, session_id: str) -> None:
"""Force compression of an AgentScope session."""
@ -403,103 +421,120 @@ class AsAgentWrapper(BaseAgentWrapper):
@classmethod
# pylint: disable=too-many-return-statements
def _event_to_chunk(cls, event: Any) -> StreamChunk | None:
"""Convert an AgentScope event to a unified StreamChunk.
Returns ``None`` for events that should be silently skipped
(e.g. ``RequireUserConfirmEvent``).
"""
def _event_to_chunk(cls, event: Any) -> StreamChunk:
"""Normalize block operations while retaining native fields and unknown events."""
native = event.model_dump(mode="json")
operation = (
"start" if str(event.type).endswith("START") else "end" if str(event.type).endswith("END") else "delta"
)
metadata = {"native": native}
common = {
"event": operation,
"turn_id": getattr(event, "reply_id", None),
"message_id": getattr(event, "reply_id", None),
"chunk": None,
"metadata": metadata,
}
if isinstance(event, ReplyStartEvent):
meta = {"reply_id": event.reply_id, "name": event.name, "role": event.role}
return cls._chunk(ChunkEnum.REPLY_START, session_id=event.session_id, chunk="", metadata=meta)
return cls._chunk(ChunkEnum.REPLY_START, **common, session_id=event.session_id, status="running")
if isinstance(event, ReplyEndEvent):
metadata["finished_reason"] = native["finished_reason"]
metadata["error"] = (native.get("error") or {}).get("message")
return cls._chunk(
ChunkEnum.REPLY_END,
**common,
session_id=event.session_id,
chunk="",
metadata={"reply_id": event.reply_id},
status=stream_status(event.finished_reason) or "waiting",
)
for event_cls, chunk_type, attr in (
(TextBlockStartEvent, ChunkEnum.CONTENT, None),
(TextBlockDeltaEvent, ChunkEnum.CONTENT, "delta"),
(TextBlockEndEvent, ChunkEnum.CONTENT, None),
(ThinkingBlockStartEvent, ChunkEnum.THINK, None),
(ThinkingBlockDeltaEvent, ChunkEnum.THINK, "delta"),
(ThinkingBlockEndEvent, ChunkEnum.THINK, None),
(DataBlockStartEvent, ChunkEnum.DATA, None),
(DataBlockDeltaEvent, ChunkEnum.DATA, "data"),
(DataBlockEndEvent, ChunkEnum.DATA, None),
for classes, chunk_type in (
((TextBlockStartEvent, TextBlockDeltaEvent, TextBlockEndEvent), ChunkEnum.CONTENT),
((ThinkingBlockStartEvent, ThinkingBlockDeltaEvent, ThinkingBlockEndEvent), ChunkEnum.THINK),
((DataBlockStartEvent, DataBlockDeltaEvent, DataBlockEndEvent), ChunkEnum.DATA),
((ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent), ChunkEnum.TOOL_CALL),
(
(ToolResultStartEvent, ToolResultTextDeltaEvent, ToolResultDataDeltaEvent, ToolResultEndEvent),
ChunkEnum.TOOL_RESULT,
),
):
if isinstance(event, event_cls):
kwargs = {"block_id": event.block_id, "chunk": getattr(event, attr) if attr else ""}
if isinstance(event, (DataBlockStartEvent, DataBlockDeltaEvent)):
kwargs["media_type"] = event.media_type
return cls._chunk(chunk_type, **kwargs)
if isinstance(event, ToolCallStartEvent):
payload = {"name": event.tool_call_name, "id": event.tool_call_id}
return cls._chunk(
ChunkEnum.TOOL_CALL,
tool_call_id=event.tool_call_id,
tool_call_name=event.tool_call_name,
chunk=json.dumps(payload),
)
if isinstance(event, ToolCallDeltaEvent):
return cls._chunk(ChunkEnum.TOOL_CALL, tool_call_id=event.tool_call_id, chunk=event.delta)
if isinstance(event, ToolCallEndEvent):
return cls._chunk(ChunkEnum.TOOL_CALL, tool_call_id=event.tool_call_id, chunk="")
if isinstance(event, ToolResultStartEvent):
return cls._chunk(
ChunkEnum.TOOL_RESULT,
tool_call_id=event.tool_call_id,
tool_call_name=event.tool_call_name,
chunk="",
)
if isinstance(event, ToolResultTextDeltaEvent):
return cls._chunk(ChunkEnum.TOOL_RESULT, tool_call_id=event.tool_call_id, chunk=event.delta)
if isinstance(event, ToolResultDataDeltaEvent):
return cls._chunk(
ChunkEnum.TOOL_RESULT,
tool_call_id=event.tool_call_id,
chunk=event.data,
media_type=event.media_type,
metadata={"url": event.url} if event.url else {},
)
if isinstance(event, ToolResultEndEvent):
return cls._chunk(
ChunkEnum.TOOL_RESULT,
tool_call_id=event.tool_call_id,
chunk="",
metadata={"state": str(event.state)},
)
if isinstance(event, classes):
common["chunk"] = getattr(event, "delta", getattr(event, "text", getattr(event, "data", None)))
if getattr(event, "url", None):
common["chunk"] = {"url": event.url}
return cls._chunk(
chunk_type,
**common,
block_id=getattr(event, "block_id", None),
tool_call_id=getattr(event, "tool_call_id", None),
tool_call_name=getattr(event, "tool_call_name", None),
media_type=getattr(event, "media_type", None),
status=stream_status(getattr(event, "state", None)),
)
if isinstance(event, ModelCallStartEvent):
return cls._chunk(ChunkEnum.USAGE, chunk="", metadata={"model_name": getattr(event, "model_name", None)})
return cls._chunk(ChunkEnum.USAGE, **common)
if isinstance(event, ModelCallEndEvent):
usage = TokenUsage(input_tokens=event.input_tokens, output_tokens=event.output_tokens)
common["chunk"] = usage.model_dump()
metadata["usage"] = usage.model_dump()
return cls._chunk(
ChunkEnum.USAGE,
chunk=json.dumps(usage.model_dump()),
**common,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
metadata={
"model_name": getattr(event, "model_name", None),
"usage": usage.model_dump(),
},
)
if isinstance(event, ExceedMaxItersEvent):
return cls._chunk(ChunkEnum.ERROR, chunk="Exceeded max iterations")
return None
common["chunk"] = "Exceeded max iterations"
return cls._chunk(ChunkEnum.ERROR, **common)
common["chunk"] = native
common["event"] = "end"
approval = str(event.type) in {"REQUIRE_USER_CONFIRM", "REQUIRE_EXTERNAL_EXECUTION"}
return cls._chunk(
ChunkEnum.APPROVAL if approval else ChunkEnum.DATA,
**common,
status="waiting" if approval else None,
)
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream agent events as unified StreamChunk objects."""
kwargs = self._merged_stream_kwargs(kwargs)
"""Execute once, streaming native events followed by the complete outcome."""
kwargs = self._merged_kwargs(kwargs)
agent, inputs = await self._build_agent(inputs, **kwargs)
async for event in agent.reply_stream(inputs):
chunk = self._event_to_chunk(event)
if chunk is not None:
chunk.session_id = chunk.session_id or agent.state.session_id
yield chunk
await self._dump_state(agent.state)
last_msg, terminal = None, None
model_usages = []
finishing = False
try:
async with aclosing(agent.reply_stream(inputs, yield_final_msg=True)) as stream:
async for event in stream:
if isinstance(event, Msg):
last_msg = event
continue
if isinstance(event, ModelCallEndEvent):
model_usages.append(self._agentscope_usage(event))
chunk = self._event_to_chunk(event)
chunk.session_id = chunk.session_id or agent.state.session_id
if isinstance(event, ReplyEndEvent):
terminal = chunk
else:
yield chunk
if last_msg is None:
raise RuntimeError("AgentScope final message not received")
if terminal is None:
if last_msg.finished_reason is not None:
raise RuntimeError("AgentScope reply_end not received")
terminal = self._chunk(
ChunkEnum.REPLY_END,
event="end",
chunk=None,
session_id=agent.state.session_id,
turn_id=last_msg.id,
status="waiting",
)
finishing = True
result = await self._finish_reply(agent, last_msg, kwargs)
finally:
if not finishing and model_usages:
self._record_token_usage(TokenUsage.combine(model_usages))
await self._dump_state(agent.state)
terminal.chunk = {key: value for key, value in result.items() if key != "events"}
terminal.status = result["status"]
terminal.metadata["error"] = result["error"]
yield terminal

View file

@ -1,6 +1,5 @@
"""Base agent wrapper component."""
from abc import abstractmethod
from collections.abc import AsyncGenerator
from importlib import metadata
from pathlib import Path
@ -11,7 +10,8 @@ from pydantic import BaseModel
from ..base_component import BaseComponent
from ..outbound_proxy import BaseOutboundProxy
from ...enumeration import ChunkEnum, ComponentEnum
from ...schema import SessionMessage, SessionSnapshot, SessionStatus, StreamChunk, TokenUsage
from ...schema import AgentReply, SessionMessage, SessionSnapshot, SessionStatus, StreamChunk, TokenUsage
from ...schema.agent_reply import AgentReplyStatus, collect_reply
from ...utils import global_counter_add_many
if TYPE_CHECKING:
@ -217,13 +217,6 @@ class BaseAgentWrapper(BaseComponent):
merged["output_schema"] = self._normalize_output_schema(merged["output_schema"])
return merged
def _merged_stream_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Merge stream options and reject unsupported structured output."""
merged = self._merged_kwargs(kwargs)
if merged.get("output_schema") is not None:
raise NotImplementedError("Structured output is not supported by reply_stream()")
return merged
@staticmethod
def _chunk(chunk_type: ChunkEnum = ChunkEnum.CONTENT, **kwargs: Any) -> StreamChunk:
"""Create a StreamChunk with a short backend-friendly call site."""
@ -246,9 +239,39 @@ class BaseAgentWrapper(BaseComponent):
},
)
@abstractmethod
async def reply(self, inputs: Any, **kwargs) -> dict:
"""Send inputs to the agent and return a dict with session_id and last_message."""
@staticmethod
def _serialize(value: Any) -> Any:
"""Keep native model/dataclass fields in a JSON-serializable form."""
from pydantic_core import to_jsonable_python
return to_jsonable_python(value, by_alias=True)
@staticmethod
def _reply_result(
*,
session_id: str,
result: str | None,
raw: dict[str, Any],
status: AgentReplyStatus = "completed",
error: str | None = None,
structured_output: Any = None,
usage: TokenUsage | None = None,
) -> AgentReply:
"""Build the same portable fields for every backend."""
return AgentReply(
session_id=session_id,
result=result or "",
structured_output=structured_output,
usage=usage.model_dump() if usage is not None else None,
status=status,
error=error,
raw=raw,
events=[],
)
async def reply(self, inputs: Any, **kwargs) -> AgentReply:
"""Collect the same execution used by streaming; errors and cancellation propagate."""
return await collect_reply(self.reply_stream(inputs, **kwargs))
async def compact_session(self, session_id: str) -> None:
"""Request compaction of one persisted agent session."""
@ -275,3 +298,5 @@ class BaseAgentWrapper(BaseComponent):
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream agent events as unified StreamChunk objects."""
raise NotImplementedError
yield # pylint: disable=unreachable

View file

@ -7,11 +7,12 @@ from contextlib import aclosing
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any, TYPE_CHECKING
from uuid import uuid4
from .base_agent_wrapper import BaseAgentWrapper
from ..component_registry import R
from ...enumeration import ChunkEnum
from ...schema import StreamChunk, TokenUsage
from ...schema import AgentReply, StreamChunk, TokenUsage
if TYPE_CHECKING:
from claude_agent_sdk import AssistantMessage, ResultMessage, UserMessage
@ -227,122 +228,83 @@ class CcAgentWrapper(BaseAgentWrapper):
raw: dict,
session_id: str | None = None,
block_states: dict[int, _BlockState] | None = None,
) -> StreamChunk | None:
"""Convert a raw Anthropic streaming event dict to a StreamChunk.
``block_states`` maps each content-block index to metadata captured at
``content_block_start`` for use by later delta and stop events.
Returns ``None`` for events that should be silently skipped.
"""
event_type = raw.get("type")
# --- Message-level lifecycle ----------------------------------------
if event_type == "message_start":
if block_states is not None:
message_id: str | None = None,
) -> StreamChunk:
"""Normalize model-message and block events, retaining unknown payloads."""
event_type = raw.get("type", "")
common = {"session_id": session_id, "message_id": message_id, "chunk": None}
if event_type in {"message_start", "message_stop"}:
if event_type == "message_start" and block_states is not None:
block_states.clear()
message = raw.get("message", {})
meta = {
"message_id": message.get("id"),
"model": message.get("model"),
"role": message.get("role"),
}
return cls._chunk(ChunkEnum.REPLY_START, session_id=session_id, chunk="", metadata=meta)
return cls._chunk(
ChunkEnum.DATA,
**common,
event="start" if event_type == "message_start" else "end",
block_id=f"message:{message_id}",
metadata={"kind": "message", "visibility": "diagnostic", "native": raw},
)
if event_type == "message_delta":
delta = raw.get("delta", {})
usage = raw.get("usage", {})
normalized = cls._claude_usage(usage)
usage = cls._claude_usage(raw.get("usage"))
common["chunk"] = usage.model_dump()
return cls._chunk(
ChunkEnum.USAGE,
session_id=session_id,
chunk=json.dumps(normalized.model_dump()),
input_tokens=normalized.input_tokens,
output_tokens=normalized.output_tokens,
**common,
event="end",
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
metadata={
"stop_reason": delta.get("stop_reason"),
"usage": normalized.model_dump(),
"stop_reason": raw.get("delta", {}).get("stop_reason"),
"usage": usage.model_dump(),
"native": raw,
},
)
if event_type == "message_stop":
return cls._chunk(ChunkEnum.REPLY_END, session_id=session_id, chunk="")
# --- Content-block lifecycle ----------------------------------------
index = raw.get("index", 0)
block = raw.get("content_block", {})
if event_type == "content_block_start":
idx, content_block = raw.get("index", 0), raw.get("content_block", {})
block_type, bid = content_block.get("type", ""), content_block.get("id", "")
state = _BlockState(
block.get("id") or f"{message_id or 'message'}:{index}",
block.get("type", ""),
block.get("name"),
)
if block_states is not None:
block_states[idx] = _BlockState(bid or None, block_type, content_block.get("name"))
if block_type == "text":
return cls._chunk(ChunkEnum.CONTENT, block_id=bid, chunk=content_block.get("text", ""))
if block_type == "thinking":
return cls._chunk(
ChunkEnum.THINK,
block_id=bid,
chunk=content_block.get("thinking", ""),
)
if block_type in {"tool_use", "server_tool_use"}:
payload = {
"name": content_block.get("name"),
"id": content_block.get("id"),
}
return cls._chunk(
ChunkEnum.TOOL_CALL,
block_id=bid,
tool_call_id=content_block.get("id"),
tool_call_name=content_block.get("name"),
chunk=json.dumps(payload),
)
return None
if event_type == "content_block_delta":
block_states[index] = state
else:
state = block_states.get(index) if block_states is not None else None
if event_type == "content_block_stop" and block_states is not None:
block_states.pop(index, None)
operation = {"content_block_start": "start", "content_block_delta": "delta", "content_block_stop": "end"}.get(
event_type,
)
if state is not None and operation:
common.update(event=operation, block_id=state.block_id)
delta = raw.get("delta", {})
delta_type = delta.get("type", "")
idx = raw.get("index", 0)
state = block_states.get(idx) if block_states else None
bid = state.block_id if state else None
tool_name = state.tool_name if state else None
if delta_type == "text_delta":
return cls._chunk(ChunkEnum.CONTENT, block_id=bid, chunk=delta.get("text", ""))
if delta_type == "thinking_delta":
return cls._chunk(ChunkEnum.THINK, block_id=bid, chunk=delta.get("thinking", ""))
if delta_type == "input_json_delta":
if event_type == "content_block_delta" and delta.get("type") not in {
"text_delta",
"thinking_delta",
"input_json_delta",
}:
return cls._chunk(ChunkEnum.DATA, **{**common, "chunk": raw}, metadata={"visibility": "diagnostic"})
metadata = {
"native": {key: value for key, value in block.items() if key not in {"text", "thinking", "input"}},
}
if state.block_type == "text":
common["chunk"] = delta.get("text") if operation == "delta" else block.get("text")
return cls._chunk(ChunkEnum.CONTENT, **common, metadata=metadata)
if state.block_type == "thinking":
common["chunk"] = delta.get("thinking") if operation == "delta" else block.get("thinking")
return cls._chunk(ChunkEnum.THINK, **common, metadata=metadata)
if state.block_type in {"tool_use", "server_tool_use"}:
common["chunk"] = delta.get("partial_json") if operation == "delta" else block.get("input") or None
return cls._chunk(
ChunkEnum.TOOL_CALL,
block_id=bid,
tool_call_id=bid,
tool_call_name=tool_name,
chunk=delta.get("partial_json", ""),
**common,
tool_call_id=state.block_id,
tool_call_name=state.tool_name,
metadata=metadata,
)
return None
if event_type == "content_block_stop":
idx = raw.get("index", 0)
state = block_states.pop(idx, None) if block_states else None
bid = state.block_id if state else None
block_type = state.block_type if state else None
tool_name = state.tool_name if state else None
if block_type in {"tool_use", "server_tool_use"}:
return cls._chunk(
ChunkEnum.TOOL_CALL,
block_id=bid,
tool_call_id=bid,
tool_call_name=tool_name,
chunk="",
)
if block_type == "thinking":
return cls._chunk(ChunkEnum.THINK, block_id=bid, chunk="")
# text or unknown -> CONTENT
return cls._chunk(ChunkEnum.CONTENT, block_id=bid, chunk="")
# Ping / other unknown types -> skip
return None
common.update(chunk=raw, event="end")
return cls._chunk(ChunkEnum.DATA, **common, metadata={"visibility": "diagnostic"})
@classmethod
def _message_content_to_chunks(
@ -351,48 +313,85 @@ class CcAgentWrapper(BaseAgentWrapper):
session_id: str | None = None,
visible_tool_call_ids: set[str] | None = None,
include_text: bool = False,
message_id: str | None = None,
) -> list[StreamChunk]:
"""Convert typed SDK content blocks that are not partial events."""
from claude_agent_sdk import ServerToolResultBlock, TextBlock, ToolResultBlock
"""Use completed SDK blocks as authoritative snapshots, including tool results."""
from claude_agent_sdk import (
ServerToolResultBlock,
ServerToolUseBlock,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
)
chunks: list[StreamChunk] = []
content = getattr(msg, "content", None)
if isinstance(content, str):
if include_text and content:
chunks.append(cls._chunk(ChunkEnum.CONTENT, session_id=session_id, chunk=content))
if include_text:
chunks.append(
cls._chunk(
ChunkEnum.CONTENT,
session_id=session_id,
message_id=message_id,
block_id=f"{message_id}:0",
event="end",
chunk=content,
),
)
return chunks
if content is None:
return chunks
for block in content:
if include_text and isinstance(block, TextBlock) and block.text:
chunks.append(cls._chunk(ChunkEnum.CONTENT, session_id=session_id, chunk=block.text))
for index, block in enumerate(content or []):
common = {
"session_id": session_id,
"message_id": message_id,
"event": "end",
"block_id": f"{message_id or 'message'}:{index}",
}
if include_text and isinstance(block, (TextBlock, ThinkingBlock)):
thinking = isinstance(block, ThinkingBlock)
chunks.append(
cls._chunk(
ChunkEnum.THINK if thinking else ChunkEnum.CONTENT,
**common,
chunk=block.thinking if thinking else block.text,
metadata={"native": {"signature": block.signature}} if thinking else {},
),
)
elif include_text and isinstance(block, (ToolUseBlock, ServerToolUseBlock)):
common["block_id"] = block.id
chunks.append(
cls._chunk(
ChunkEnum.TOOL_CALL,
**common,
tool_call_id=block.id,
tool_call_name=block.name,
chunk=block.input,
),
)
if visible_tool_call_ids is not None:
visible_tool_call_ids.add(block.id)
elif isinstance(block, (ToolResultBlock, ServerToolResultBlock)):
tool_use_id = block.tool_use_id
if visible_tool_call_ids is not None and tool_use_id not in visible_tool_call_ids:
if visible_tool_call_ids is not None and block.tool_use_id not in visible_tool_call_ids:
continue
payload: dict[str, Any] = {
"tool_use_id": tool_use_id,
"content": block.content,
}
common["block_id"] = block.tool_use_id
payload = {"tool_use_id": block.tool_use_id, "content": block.content}
if isinstance(block, ToolResultBlock):
payload["is_error"] = block.is_error
chunks.append(
cls._chunk(
ChunkEnum.TOOL_RESULT,
session_id=session_id,
block_id=tool_use_id,
tool_call_id=tool_use_id,
**common,
tool_call_id=block.tool_use_id,
chunk=payload,
status="failed" if payload.get("is_error") else "completed",
),
)
return chunks
@classmethod
def _result_error_text(cls, msg: "ResultMessage") -> str:
"""Return the error text used by both the SDK and unified chunks."""
return "; ".join(msg.errors or []) or str(msg.subtype)
return "; ".join(msg.errors or []) or msg.result or str(msg.subtype)
@classmethod
def _result_message_to_chunks(cls, msg: "ResultMessage") -> list[StreamChunk]:
@ -418,6 +417,7 @@ class CcAgentWrapper(BaseAgentWrapper):
"permission_denials": msg.permission_denials,
"deferred_tool_use": (asdict(msg.deferred_tool_use) if msg.deferred_tool_use else None),
"api_error_status": msg.api_error_status,
"native": cls._serialize(msg),
},
),
]
@ -437,39 +437,11 @@ class CcAgentWrapper(BaseAgentWrapper):
async def compact_session(self, session_id: str) -> None:
"""Compact a Claude Code session through its native command."""
result = await self.reply("/compact", resume=session_id)
if result["last_message"].get("is_error"):
raise RuntimeError("Claude Code session compaction failed")
async def reply(self, inputs: Any, **kwargs) -> dict:
from claude_agent_sdk import query, ResultMessage
kwargs = self._merged_kwargs(kwargs)
opts = self._build_options(inputs, stream=False, **kwargs)
last_msg = None
async with aclosing(query(prompt=inputs, options=opts)) as stream:
async for msg in stream:
if isinstance(msg, ResultMessage):
last_msg = msg
if last_msg is None:
raise ValueError("No message received from Claude Code.")
usage = self._claude_usage(last_msg.usage) if last_msg.usage is not None else None
result = {
"session_id": last_msg.session_id or "",
"last_message": asdict(last_msg),
"result": last_msg.result,
"usage": usage.model_dump() if usage is not None else None,
}
if usage is not None:
self._record_token_usage(usage)
if kwargs.get("output_schema") is not None:
result["structured_output"] = last_msg.structured_output
return result
if result["status"] != "completed":
raise RuntimeError(result["error"] or f"Claude Code session compaction {result['status']}")
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream Claude Code events as unified StreamChunk objects."""
"""Stream one invocation, keeping model-message boundaries inside it."""
from claude_agent_sdk import (
AssistantMessage,
MirrorErrorMessage,
@ -481,104 +453,146 @@ class CcAgentWrapper(BaseAgentWrapper):
UserMessage,
)
kwargs = self._merged_stream_kwargs(kwargs)
kwargs = self._merged_kwargs(kwargs)
opts = self._build_options(inputs, stream=True, **kwargs)
block_states: dict[int, _BlockState] = {}
block_states: dict[str | None, dict[int, _BlockState]] = {}
visible_tool_call_ids: set[str] = set()
current_session_id: str | None = None
turn_id = uuid4().hex
partial_message_ids: dict[str | None, str] = {}
current_session_id = None
emitted_content = False
emitted_reply_end = False
reply_open = False
expected_trailing_error: str | None = None
started = False
result: AgentReply | None = None
usage = None
expected_trailing_error = None
try:
async with aclosing(query(prompt=inputs, options=opts)) as stream:
async for msg in stream:
current_session_id = getattr(msg, "session_id", None) or current_session_id
if not started:
started = True
yield self._chunk(
ChunkEnum.REPLY_START,
event="start",
turn_id=turn_id,
session_id=current_session_id,
status="running",
)
if expected_trailing_error is not None and not (
isinstance(msg, SystemMessage) and msg.subtype == "session_state_changed"
):
expected_trailing_error = None
chunks = []
parent_id = getattr(msg, "parent_tool_use_id", None)
if isinstance(msg, StreamEvent):
current_session_id = msg.session_id or current_session_id
if msg.event.get("type") == "message_start":
partial_message_ids[parent_id] = msg.event.get("message", {}).get("id") or msg.uuid
chunk = self._raw_event_to_chunk(
msg.event,
session_id=msg.session_id,
block_states=block_states,
session_id=current_session_id,
block_states=block_states.setdefault(parent_id, {}),
message_id=partial_message_ids.get(parent_id),
)
if chunk is not None:
chunk.session_id = chunk.session_id or msg.session_id
if chunk.chunk_type == ChunkEnum.TOOL_CALL and chunk.tool_call_id:
visible_tool_call_ids.add(chunk.tool_call_id)
if chunk.chunk_type == ChunkEnum.CONTENT and chunk.chunk:
emitted_content = True
if chunk.chunk_type == ChunkEnum.REPLY_START:
reply_open = True
if chunk.chunk_type == ChunkEnum.REPLY_END:
emitted_reply_end = True
reply_open = False
yield chunk
elif isinstance(msg, UserMessage):
for chunk in self._message_content_to_chunks(msg, current_session_id, visible_tool_call_ids):
yield chunk
chunk.metadata["parent_tool_use_id"] = msg.parent_tool_use_id
if chunk.chunk_type == ChunkEnum.TOOL_CALL and chunk.tool_call_id:
visible_tool_call_ids.add(chunk.tool_call_id)
chunks.append(chunk)
elif isinstance(msg, (AssistantMessage, UserMessage)):
message_id = (
getattr(msg, "message_id", None)
or partial_message_ids.get(parent_id)
or getattr(msg, "uuid", None)
or uuid4().hex
)
chunks = self._message_content_to_chunks(
msg,
current_session_id,
visible_tool_call_ids,
include_text=isinstance(msg, AssistantMessage),
message_id=message_id,
)
if isinstance(msg, AssistantMessage):
partial_message_ids.pop(parent_id, None)
elif isinstance(msg, ResultMessage):
if msg.is_error:
expected_trailing_error = (
f"Claude Code returned an error result: {self._result_error_text(msg)}"
)
current_session_id = msg.session_id or current_session_id
if not emitted_content and msg.result:
emitted_content = True
yield self._chunk(
ChunkEnum.CONTENT,
session_id=msg.session_id or "",
chunk=msg.result,
chunks.append(
self._chunk(
ChunkEnum.CONTENT,
event="end",
block_id=f"{turn_id}:result",
chunk=msg.result,
),
)
for chunk in self._result_message_to_chunks(msg):
yield chunk
if reply_open or not emitted_reply_end:
emitted_reply_end = True
reply_open = False
yield self._chunk(
ChunkEnum.REPLY_END,
session_id=current_session_id,
chunk="",
)
elif isinstance(msg, AssistantMessage):
current_session_id = msg.session_id or current_session_id
for chunk in self._message_content_to_chunks(
msg,
current_session_id,
visible_tool_call_ids,
include_text=not emitted_content,
):
if chunk.chunk_type == ChunkEnum.CONTENT and chunk.chunk:
emitted_content = True
yield chunk
chunks.extend(self._result_message_to_chunks(msg))
status = "completed"
if msg.terminal_reason in {"aborted_streaming", "aborted_tools"}:
status = "interrupted"
elif msg.is_error or msg.subtype != "success":
status = "failed"
elif msg.deferred_tool_use is not None:
status = "waiting"
usage = self._claude_usage(msg.usage) if msg.usage is not None else None
result = self._reply_result(
session_id=msg.session_id or "",
result=msg.result,
structured_output=msg.structured_output,
usage=usage,
status=status,
error=self._result_error_text(msg) if status == "failed" else None,
raw=self._serialize(msg),
)
elif isinstance(msg, MirrorErrorMessage):
self.logger.warning(f"Claude Code session mirror failed: {msg.error}")
yield self._chunk(
ChunkEnum.DATA,
session_id=current_session_id,
chunk=f"Session mirror failed: {msg.error}",
metadata={
"event": "session_mirror_error",
"session_key": msg.key,
},
chunks.append(
self._chunk(
ChunkEnum.DATA,
event="end",
chunk=f"Session mirror failed: {msg.error}",
metadata={"event": "session_mirror_error", "session_key": msg.key},
),
)
elif isinstance(msg, RateLimitEvent) and msg.rate_limit_info.status == "rejected":
yield self._chunk(
ChunkEnum.ERROR,
session_id=msg.session_id,
chunk="Rate limit exceeded",
elif isinstance(msg, RateLimitEvent):
if msg.rate_limit_info.status == "rejected":
chunks.append(self._chunk(ChunkEnum.ERROR, chunk="Rate limit exceeded"))
else:
chunks.append(
self._chunk(
ChunkEnum.DATA,
event="end",
chunk=self._serialize(msg),
metadata={"visibility": "diagnostic"},
),
)
if not chunks:
chunks = [self._chunk(ChunkEnum.DATA, chunk=None, metadata={"visibility": "diagnostic"})]
for chunk in chunks:
chunk.metadata.pop("native", None)
chunks[0].metadata["native"] = self._serialize(msg)
for chunk in chunks:
chunk.metadata["parent_tool_use_id"] = parent_id
chunk.turn_id = turn_id
chunk.session_id = chunk.session_id or current_session_id
if chunk.chunk_type == ChunkEnum.CONTENT and chunk.chunk:
emitted_content = True
yield chunk
except Exception as exc:
if expected_trailing_error is None or str(exc) != expected_trailing_error:
raise
self.logger.debug(f"Ignoring Claude Code process exit after error result: {exc}")
finally:
if usage is not None:
self._record_token_usage(usage)
if result is None:
raise RuntimeError("Claude Code result message not received")
yield self._chunk(
ChunkEnum.REPLY_END,
event="end",
session_id=result["session_id"],
turn_id=turn_id,
status=result["status"],
chunk={key: value for key, value in result.items() if key != "events"},
metadata={"error": result["error"]},
)

View file

@ -12,11 +12,13 @@ import os
from typing import Any, TYPE_CHECKING
from .base_agent_wrapper import BaseAgentWrapper
from .codex_session_history import item_chunks
from ..codex_app_server import CodexAppServer
from ..component_registry import R
from ..job import BackgroundJob, StreamJob
from ...enumeration import ChunkEnum
from ...schema import SessionMessage, SessionSnapshot, SessionStatus, StreamChunk, TokenUsage
from ...schema import AgentReply, SessionMessage, SessionSnapshot, SessionStatus, StreamChunk, TokenUsage
from ...schema.stream_chunk import stream_status
if TYPE_CHECKING:
from openai_codex import AsyncThread, RunInput
@ -76,12 +78,12 @@ class CodexAgentWrapper(BaseAgentWrapper):
if invalid:
raise TypeError(f"Configure Codex process options on codex_app_server: {', '.join(invalid)}")
def _reply_kwargs(self, kwargs: dict[str, Any], *, stream: bool = False) -> dict[str, Any]:
def _reply_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
self._reject_server_options(kwargs)
invalid = sorted({"app_server", "cwd"}.intersection(kwargs))
if invalid:
raise TypeError(f"Configure Codex wrapper options on the component: {', '.join(invalid)}")
return self._merged_stream_kwargs(kwargs) if stream else self._merged_kwargs(kwargs)
return self._merged_kwargs(kwargs)
def _ensure_skills(self, skills: list[str] | str | None) -> None:
"""Expose selected project skills through Codex's repo-level directory."""
@ -187,12 +189,6 @@ class CodexAgentWrapper(BaseAgentWrapper):
except Exception as exc:
self.logger.warning(f"Failed to interrupt Codex turn {turn.id}: {exc}")
@staticmethod
def _serialize(value: Any) -> Any:
from pydantic_core import to_jsonable_python
return to_jsonable_python(value, by_alias=True)
async def _turn_stream(self, turn: Any, usage: _TurnUsage) -> AsyncIterator[Notification]:
"""Observe only this turn's reported spending, including compaction calls."""
stream = turn.stream()
@ -210,38 +206,55 @@ class CodexAgentWrapper(BaseAgentWrapper):
finally:
await stream.aclose()
async def reply(self, inputs: RunInput, **kwargs) -> dict:
"""Run an isolated or explicitly resumed conversation on the shared server."""
from openai_codex._run import _collect_async_turn_result
def _finish_reply(self, session_id, completed, items, native_usage, usage, kwargs) -> AgentReply:
"""Build the native TurnResult and portable outcome from this turn's events."""
from openai_codex import TurnResult
kwargs = self._reply_kwargs(kwargs)
async with self._session(kwargs) as thread:
usage = _TurnUsage(await self.app_server.token_usage(thread.id))
turn = await self.app_server.turn(thread, inputs, **self._turn_kwargs(kwargs))
stream = self._turn_stream(turn, usage)
by_id = {item.root.id: item for item in items}
by_id.update({item.root.id: item for item in completed.items})
items = list(by_id.values())
final_response = None
fallback = None
for wrapped in reversed(items):
item = wrapped.root
if item.type != "agentMessage":
continue
if item.phase is not None and item.phase.value == "final_answer":
final_response = item.text
break
if item.phase is None and fallback is None:
fallback = item.text
if final_response is None:
final_response = fallback
result = TurnResult(
id=completed.id,
status=completed.status,
error=completed.error,
started_at=completed.started_at,
completed_at=completed.completed_at,
duration_ms=completed.duration_ms,
final_response=final_response,
items=items,
usage=native_usage,
)
status = completed.status.value
error = completed.error.message if completed.error else None
structured_output = None
if status == "completed" and kwargs.get("output_schema") is not None:
try:
result = await _collect_async_turn_result(stream, turn_id=turn.id)
except BaseException:
await self._interrupt(turn)
raise
finally:
if usage.usage is not None:
self._record_token_usage(usage.usage)
await stream.aclose()
final_response = result.final_response or ""
response = {
"session_id": thread.id,
"last_message": final_response,
"result": final_response,
"turn": self._serialize(result),
"usage": usage.usage.model_dump() if usage.usage is not None else None,
}
if kwargs.get("output_schema") is not None:
try:
response["structured_output"] = json.loads(final_response)
except json.JSONDecodeError as exc:
raise ValueError("Codex returned invalid JSON for the requested output_schema") from exc
return response
structured_output = json.loads(final_response or "")
except json.JSONDecodeError:
status = "failed"
error = "Codex returned invalid JSON for the requested output_schema"
return self._reply_result(
session_id=session_id,
result=final_response,
structured_output=structured_output,
usage=usage,
status=status,
error=error,
raw=self._serialize(result),
)
async def compact_session(self, session_id: str) -> None:
"""Hold the conversation lock until native compaction has finished."""
@ -297,13 +310,39 @@ class CodexAgentWrapper(BaseAgentWrapper):
) -> list[StreamChunk]:
"""Map notifications; usage replaces the cumulative counts for its turn block."""
method, payload = event.method, event.payload
make_chunk = partial(cls._chunk, session_id=session_id)
make_chunk = partial(cls._chunk, session_id=session_id, turn_id=getattr(payload, "turn_id", None))
if method == "turn/started":
return [make_chunk(ChunkEnum.REPLY_START, metadata={"turn_id": payload.turn.id})]
return [
cls._chunk(
ChunkEnum.REPLY_START,
event="start",
session_id=session_id,
turn_id=payload.turn.id,
status="running",
metadata={"turn_id": payload.turn.id},
),
]
if method == "item/agentMessage/delta":
return [make_chunk(ChunkEnum.CONTENT, block_id=payload.item_id, chunk=payload.delta)]
return [
make_chunk(
ChunkEnum.CONTENT,
block_id=payload.item_id,
message_id=payload.item_id,
chunk=payload.delta,
),
]
if method in {"item/reasoning/summaryTextDelta", "item/reasoning/textDelta", "item/plan/delta"}:
return [make_chunk(ChunkEnum.THINK, block_id=payload.item_id, chunk=payload.delta)]
kind = {"item/reasoning/summaryTextDelta": "summary", "item/reasoning/textDelta": "content"}.get(method)
index = getattr(payload, "summary_index" if kind == "summary" else "content_index", 0)
return [
make_chunk(
ChunkEnum.THINK,
block_id=f"{payload.item_id}:{kind}:{index}" if kind else payload.item_id,
message_id=payload.item_id,
chunk=payload.delta,
metadata={"kind": f"reasoning_{kind}" if kind else "plan", "index": index},
),
]
if method in {"item/commandExecution/outputDelta", "item/fileChange/outputDelta"}:
return [
make_chunk(
@ -311,6 +350,7 @@ class CodexAgentWrapper(BaseAgentWrapper):
block_id=payload.item_id,
tool_call_id=payload.item_id,
chunk=payload.delta,
status="running",
),
]
if method == "item/mcpToolCall/progress":
@ -320,6 +360,7 @@ class CodexAgentWrapper(BaseAgentWrapper):
block_id=payload.item_id,
tool_call_id=payload.item_id,
chunk=payload.message,
status="running",
),
]
if method in {"item/autoApprovalReview/started", "item/autoApprovalReview/completed"}:
@ -335,6 +376,8 @@ class CodexAgentWrapper(BaseAgentWrapper):
block_id=target_item_id or review_id,
tool_call_id=target_item_id,
chunk=action,
event="start" if status == "started" else "end",
status="running" if status == "started" else stream_status((review or {}).get("status")),
metadata={
"review_id": review_id,
"status": status,
@ -345,22 +388,12 @@ class CodexAgentWrapper(BaseAgentWrapper):
),
]
if method in {"item/started", "item/completed"}:
item = payload.item.root
item_type, item_id = item.type, item.id
tool_types = {"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall"}
if item_type not in tool_types:
return []
name = getattr(item, "tool", None) or item_type
chunk_type = ChunkEnum.TOOL_CALL if method == "item/started" else ChunkEnum.TOOL_RESULT
return [
make_chunk(
chunk_type,
block_id=item_id,
tool_call_id=item_id,
tool_call_name=name,
chunk=cls._serialize(item),
),
]
return item_chunks(
payload.item.root,
event="start" if method == "item/started" else "end",
session_id=session_id,
turn_id=payload.turn_id,
)
if method == "thread/tokenUsage/updated":
if usage is None:
return []
@ -371,7 +404,11 @@ class CodexAgentWrapper(BaseAgentWrapper):
chunk=usage.model_dump(),
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
metadata={"usage": usage.model_dump()},
metadata={
"usage": usage.model_dump(),
"native": cls._serialize(getattr(payload, "token_usage", None)),
},
event="end",
),
]
if method == "error":
@ -379,17 +416,25 @@ class CodexAgentWrapper(BaseAgentWrapper):
make_chunk(
ChunkEnum.ERROR,
chunk=payload.error.message,
metadata={"will_retry": payload.will_retry},
metadata={"will_retry": payload.will_retry, "native": cls._serialize(payload.error)},
),
]
if method == "turn/completed":
turn = payload.turn
chunks = []
chunks = [
chunk
for item in getattr(turn, "items", [])
for chunk in item_chunks(getattr(item, "root", item), session_id=session_id, turn_id=turn.id)
]
if turn.error:
chunks.append(make_chunk(ChunkEnum.ERROR, chunk=turn.error.message))
chunks.append(
make_chunk(
cls._chunk(
ChunkEnum.REPLY_END,
event="end",
session_id=session_id,
turn_id=turn.id,
status=stream_status(turn.status),
metadata={
"turn_id": turn.id,
"status": turn.status.value,
@ -398,39 +443,60 @@ class CodexAgentWrapper(BaseAgentWrapper):
),
)
return chunks
if getattr(payload, "turn_id", None):
return [
make_chunk(
ChunkEnum.DATA,
block_id=getattr(payload, "item_id", None),
chunk=cls._serialize(payload),
metadata={"codex_method": method},
),
]
return []
diagnostic = method.startswith("hook/") or not getattr(payload, "turn_id", None)
return [
make_chunk(
ChunkEnum.DATA,
block_id=getattr(payload, "item_id", None),
chunk=cls._serialize(payload),
metadata={"codex_method": method, **({"visibility": "diagnostic"} if diagnostic else {})},
),
]
async def reply_stream(self, inputs: RunInput, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream notifications routed by the SDK to this conversation's turn."""
kwargs = self._reply_kwargs(kwargs, stream=True)
"""Execute once, retaining every notification and a complete terminal outcome."""
kwargs = self._reply_kwargs(kwargs)
emitted_usage = None
async with self._session(kwargs) as thread:
usage = _TurnUsage(await self.app_server.token_usage(thread.id))
turn = await self.app_server.turn(thread, inputs, **self._turn_kwargs(kwargs))
stream = self._turn_stream(turn, usage)
completed = False
items, native_usage, completed, terminal = [], None, None, None
try:
async for event in stream:
if event.method == "turn/completed":
completed = True
payload = event.payload
if event.method == "item/completed" and payload.turn_id == turn.id:
items.append(payload.item)
elif event.method == "thread/tokenUsage/updated" and payload.turn_id == turn.id:
native_usage = payload.token_usage
elif event.method == "turn/completed" and payload.turn.id == turn.id:
completed = payload.turn
update = usage.usage if usage.usage != emitted_usage else None
chunks = self._event_to_chunks(event, thread.id, usage=update)
if event.method == "thread/tokenUsage/updated":
if usage.usage == emitted_usage:
continue
emitted_usage = usage.usage
for chunk in self._event_to_chunks(event, thread.id, usage=usage.usage):
yield chunk
if not chunks:
chunks = [self._chunk(ChunkEnum.DATA, chunk=None, metadata={"visibility": "diagnostic"})]
for chunk in chunks:
chunk.metadata.pop("native", None)
chunks[0].metadata["native"] = {"method": event.method, "payload": self._serialize(payload)}
for chunk in chunks:
chunk.session_id = thread.id
chunk.turn_id = chunk.turn_id or turn.id
if chunk.chunk_type == ChunkEnum.REPLY_END:
terminal = chunk
else:
yield chunk
if completed is None or terminal is None:
raise RuntimeError("Codex turn completed event not received")
result = self._finish_reply(thread.id, completed, items, native_usage, usage.usage, kwargs)
finally:
if usage.usage is not None:
self._record_token_usage(usage.usage)
if not completed:
if completed is None:
await self._interrupt(turn)
await stream.aclose()
terminal.chunk = {key: value for key, value in result.items() if key != "events"}
terminal.status = result["status"]
terminal.metadata["error"] = result["error"]
yield terminal

View file

@ -9,6 +9,7 @@ from uuid import uuid4
from ...enumeration import ChunkEnum
from ...schema import SessionMessage, SessionSnapshot, SessionStatus, StreamChunk, TokenUsage
from ...schema.stream_chunk import stream_status
_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}\Z")
@ -125,6 +126,70 @@ def new_record(message_id: str, content: str, thread) -> dict:
}
def item_chunks(item, *, event="end", session_id=None, turn_id=None) -> list[StreamChunk]:
"""Project the same native item snapshots for live streams and history."""
payload = item.model_dump(mode="json", by_alias=True)
common = {"event": event, "session_id": session_id, "turn_id": turn_id, "message_id": item.id}
if item.type == "userMessage":
return []
if item.type in {"agentMessage", "plan", "reasoning"}:
metadata = {
"native": {key: value for key, value in payload.items() if key not in {"text", "summary", "content"}},
}
if item.type == "reasoning":
return [
StreamChunk(
**common,
chunk_type=ChunkEnum.THINK,
block_id=f"{item.id}:{kind}:{index}",
chunk=text,
metadata={**metadata, "kind": f"reasoning_{kind}", "index": index},
)
for kind in ("summary", "content")
for index, text in enumerate(getattr(item, kind, None) or [])
]
return [
StreamChunk(
**common,
chunk_type=ChunkEnum.CONTENT if item.type == "agentMessage" else ChunkEnum.THINK,
block_id=item.id,
chunk=item.text,
metadata={**metadata, "kind": item.type, "phase": payload.get("phase")},
),
]
if item.type in {
"commandExecution",
"fileChange",
"mcpToolCall",
"dynamicToolCall",
"collabAgentToolCall",
"webSearch",
}:
native_status = getattr(item, "status", None)
status = (
stream_status(native_status)
if native_status is not None
else "running" if event == "start" else "completed"
)
if event == "end":
if getattr(item, "error", None) or getattr(item, "success", None) is False:
status = "failed"
if getattr(item, "exit_code", None) not in {None, 0}:
status = "failed"
return [
StreamChunk(
**common,
chunk_type=ChunkEnum.TOOL_CALL if event == "start" else ChunkEnum.TOOL_RESULT,
block_id=item.id,
tool_call_id=item.id,
tool_call_name=getattr(item, "tool", None) or item.type,
chunk=payload,
status=status,
),
]
return [StreamChunk(**common, chunk_type=ChunkEnum.DATA, block_id=item.id, chunk=payload)]
def session_snapshot(thread, records: list[dict], recovered_ids: set[str]) -> SessionSnapshot:
"""Combine native turn groups and durable proactive messages in insertion order."""
proactive: dict[str | None, list[SessionMessage]] = {}
@ -140,35 +205,17 @@ def session_snapshot(thread, records: list[dict], recovered_ids: set[str]) -> Se
item = wrapped.root
if item.type == "userMessage":
users.extend(part.root.text for part in item.content if part.root.type == "text")
elif item.type == "agentMessage":
chunks.append(StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk=item.text, block_id=item.id))
elif item.type in {"reasoning", "plan"}:
text = item.text if item.type == "plan" else "\n".join(item.summary or item.content or [])
if text:
chunks.append(StreamChunk(chunk_type=ChunkEnum.THINK, chunk=text, block_id=item.id))
elif item.type in {
"commandExecution",
"fileChange",
"mcpToolCall",
"dynamicToolCall",
"collabAgentToolCall",
"webSearch",
}:
chunks.append(
StreamChunk(
chunk_type=ChunkEnum.TOOL_RESULT,
chunk=item.model_dump(mode="json", by_alias=True),
tool_call_id=item.id,
tool_call_name=getattr(item, "tool", None) or item.type,
metadata={"state": getattr(getattr(item, "status", None), "value", "completed")},
),
)
else:
chunks.extend(item_chunks(item, session_id=thread.id, turn_id=turn.id))
if users:
messages.append(
SessionMessage(id=f"{turn.id}:user", role="user", content="\n".join(users), turn_id=turn.id),
)
if getattr(turn, "error", None):
chunks.append(StreamChunk(chunk_type=ChunkEnum.ERROR, chunk=turn.error.message))
status = stream_status(getattr(turn, "status", None))
if chunks and status != "running":
chunks.append(StreamChunk(chunk_type=ChunkEnum.REPLY_END, event="end", turn_id=turn.id, status=status))
if chunks:
messages.append(
SessionMessage(

View file

@ -54,24 +54,19 @@ class HttpClient(BaseClient):
continue
data_str = line[len("data:") :]
if data_str.strip() == "[DONE]":
yield StreamChunk(chunk_type=ChunkEnum.DONE, chunk="", done=True)
return
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
data = json.loads(data_str)
chunk = StreamChunk(**data)
if chunk.chunk_type == ChunkEnum.ERROR and chunk.metadata.get("will_retry") is not True:
# Recoverable notifications remain visible; terminal errors stop the request.
raise RuntimeError(str(chunk.chunk))
if chunk.done:
return
yield chunk
else:
body = await resp.aread()
yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk=body.decode())
if chunk.done or chunk.chunk_type == ChunkEnum.DONE:
return
raise RuntimeError("Stream ended without DONE")
body = await resp.aread()
yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk=body.decode())
async def stream_chunks(self, action: str, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""HTTP-specific richer access: yield raw StreamChunk objects (no display formatting)."""
"""Yield the complete event stream, including errors and DONE, without display formatting."""
async for chunk in self._iter_stream_chunks(action, kwargs):
yield chunk
@ -115,8 +110,31 @@ class HttpClient(BaseClient):
# pylint: disable=invalid-overridden-method
async def _execute(self, action: str, payload: dict) -> AsyncGenerator[str, None]:
"""Yield text chunks for CLI display; JSON responses are pretty-formatted."""
text_blocks: dict[str, str] = {}
async for chunk in self._iter_stream_chunks(action, payload):
if chunk.chunk_type == ChunkEnum.ERROR and chunk.metadata.get("will_retry") is not True:
raise RuntimeError(str(chunk.chunk))
if chunk.chunk_type == ChunkEnum.DONE:
continue
if chunk.chunk_type == ChunkEnum.REPLY_END and chunk.status != "completed":
outcome = chunk.chunk if isinstance(chunk.chunk, dict) else {}
raise RuntimeError(
outcome.get("error") or chunk.metadata.get("error") or f"Agent reply ended: {chunk.status}",
)
chunk_payload = chunk.chunk
if chunk.chunk_type in {ChunkEnum.REPLY_START, ChunkEnum.REPLY_END}:
continue
if chunk_payload is None or chunk.metadata.get("visibility") == "diagnostic":
continue
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk_payload, str) and chunk.block_id:
previous = text_blocks.get(chunk.block_id, "")
text_blocks[chunk.block_id] = previous + chunk_payload if chunk.event == "delta" else chunk_payload
if chunk.event != "delta":
chunk_payload = (
chunk_payload[len(previous) :] if chunk_payload.startswith(previous) else "\n" + chunk_payload
)
if not chunk_payload:
continue
text = chunk_payload if isinstance(chunk_payload, str) else json.dumps(chunk_payload, ensure_ascii=False)
yield self._format_for_display(text)

View file

@ -131,7 +131,7 @@ class BaseJob(BaseComponent):
else:
if execution is not None:
if not context.response.success:
status, error = "failed", str(context.response.answer)
status, error = "failed", context.stream_error or str(context.response.answer)
elif context.stream_error is not None:
status, error = "failed", context.stream_error
elif context.stop_event is not None and context.stop_event.is_set():

View file

@ -21,5 +21,6 @@ class StreamJob(BaseJob):
context.response.success = False
context.response.answer = _describe_exception(e)
await context.add_stream_string(str(e), ChunkEnum.ERROR)
context.stream_error = context.response.answer
# Always emit DONE so consumers can detach even after an error.
await context.add_stream_done()

View file

@ -75,6 +75,10 @@ class RuntimeContext:
raise RuntimeError("Stream queue not initialized")
if chunk.chunk_type == ChunkEnum.ERROR and chunk.metadata.get("will_retry") is not True:
self.stream_error = str(chunk.chunk)
if chunk.chunk_type == ChunkEnum.REPLY_END and chunk.status == "failed":
self.stream_error = self.stream_error or str(
chunk.metadata.get("error") or chunk.metadata.get("finished_reason") or "Agent reply failed",
)
await self.stream_queue.put(chunk)
async def add_stream_string(self, chunk: str, chunk_type: ChunkEnum) -> "RuntimeContext":

View file

@ -20,14 +20,13 @@ class ChunkEnum(str, Enum):
ExceedMaxItersEvent -> ERROR
Claude Code SDK events -> ChunkEnum mapping:
message_start -> REPLY_START
message_start/stop -> DATA (model-message boundaries)
message_delta -> USAGE
message_stop -> REPLY_END
content_block_start/delta/stop (text) -> CONTENT
content_block_start/delta/stop (thinking) -> THINK
content_block_start/delta/stop (tool_use) -> TOOL_CALL
ToolResultBlock -> TOOL_RESULT
ResultMessage -> USAGE
ResultMessage -> USAGE + REPLY_END
ResultMessage.is_error -> ERROR
Codex app-server notifications follow the same lifecycle categories;

View file

@ -1,6 +1,7 @@
"""Schema"""
from .application_config import ApplicationConfig, ComponentConfig, JobConfig
from .agent_reply import AgentReply, collect_reply, replay_reply
from .agent_session import SessionMessage, SessionSnapshot, SessionStatus
from .compaction import CallDecision, CompactionOptions, CompactionRequest, CompactionResult
from .dream import (
@ -29,6 +30,9 @@ from .token_usage import TokenUsage
from .traverse_graph import TraverseGraph, TraverseGraphEdge, TraverseGraphNode
__all__ = [
"AgentReply",
"collect_reply",
"replay_reply",
"CallDecision",
"CompactionOptions",
"CompactionRequest",

View file

@ -0,0 +1,61 @@
"""Agent outcomes and lossless collection/replay of their event stream."""
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import aclosing
from copy import deepcopy
from typing import Any, Literal, TypedDict
from .stream_chunk import StreamChunk
from ..enumeration import ChunkEnum
AgentReplyStatus = Literal["completed", "failed", "interrupted", "waiting"]
class AgentReply(TypedDict):
"""One invocation's outcome and complete, ordered JSON event trace.
``result`` is final reply text; ``usage`` covers this invocation only.
``raw`` contains JSON-serializable native results, including provider
usage details and termination reasons omitted from the portable fields.
"""
session_id: str
result: str
structured_output: Any
usage: dict[str, int] | None
status: AgentReplyStatus
error: str | None
raw: dict[str, Any]
events: list[dict[str, Any]]
async def collect_reply(
stream: AsyncGenerator[StreamChunk, None],
on_chunk: Callable[[StreamChunk], Awaitable[Any]] | None = None,
) -> AgentReply:
"""Collect an invocation without deriving its outcome from display text.
The terminal reply_end carries the same outcome fields as AgentReply.
Missing/duplicate outcomes fail explicitly; DONE is only a transport marker.
"""
events = []
outcome = None
async with aclosing(stream):
async for chunk in stream:
events.append(chunk.model_dump(mode="json"))
if on_chunk is not None:
await on_chunk(chunk)
if chunk.chunk_type == ChunkEnum.REPLY_END:
required = AgentReply.__required_keys__ - {"events"}
if outcome is not None or not isinstance(chunk.chunk, dict) or not required <= chunk.chunk.keys():
raise ValueError("Expected exactly one complete reply_end outcome")
outcome = chunk.chunk
if outcome is None:
raise ValueError("Agent stream ended without a reply_end outcome")
return AgentReply(**{**outcome, "events": events})
async def replay_reply(reply: AgentReply) -> AsyncGenerator[StreamChunk, None]:
"""Replay fresh chunks in their original order, including native metadata."""
for event in reply["events"]:
yield StreamChunk.model_validate(deepcopy(event))

View file

@ -1,43 +1,61 @@
"""Stream chunk schema for incremental responses (e.g. LLM streaming)."""
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field, JsonValue
from ..enumeration import ChunkEnum
StreamStatus = Literal["running", "completed", "failed", "interrupted", "waiting"]
def stream_status(value: Any) -> StreamStatus | None:
"""Normalize provider execution states without treating unknown states as success."""
return {
"inProgress": "running",
"running": "running",
"started": "running",
"completed": "completed",
"success": "completed",
"succeeded": "completed",
"error": "failed",
"failed": "failed",
"declined": "failed",
"denied": "failed",
"exceed_max_iters": "failed",
"interrupted": "interrupted",
"cancelled": "interrupted",
"waiting": "waiting",
}.get(getattr(value, "value", value))
class StreamChunk(BaseModel):
"""A single chunk in a unified streaming response sequence.
"""Backend-independent display event, with provider extras in metadata.
Carries full information from AgentScope, Claude Code SDK, and Codex
backends. Optional fields are ``None`` by default so that simple
text-only streams (e.g. plain CONTENT deltas) stay lightweight.
``start`` creates a block, ``delta`` appends content, and ``end`` settles it.
A non-null start/end payload is a complete snapshot replacing accumulated
content; null means no content update (an empty string is a valid snapshot).
Simple text producers use the default ``delta`` operation.
Fields:
chunk_type: Category of this chunk (see ChunkEnum).
chunk: Payload, typically a text delta, but can be
dict/list for structured data (tool-call JSON,
usage stats, etc.).
done: Terminal marker. True only for the final DONE chunk.
session_id: Backend session identifier (Codex uses its thread id).
block_id: Content-block identifier for matching start/delta/end
sequences (both backends assign block IDs).
tool_call_id: Tool-call identifier for correlating call deltas
with their start event and result.
tool_call_name: Name of the tool being invoked.
media_type: MIME type for DATA blocks (e.g. ``"image/png"``).
input_tokens: Prompt tokens consumed (populated on USAGE chunks).
output_tokens: Completion tokens generated (populated on USAGE chunks).
metadata: Backend-specific extras that don't warrant a dedicated
field.
Reply markers delimit one agent invocation, including its tool/model loop.
DONE closes the transport and does not imply success. IDs correlate a
session, invocation (turn), model message, content block, and tool call.
``reply_end.chunk`` carries the complete outcome (excluding its event trace).
``metadata.native`` preserves complete provider events; ``metadata.kind``
distinguishes plans, reasoning summaries, and other display subtypes.
"""
model_config = ConfigDict(extra="allow")
chunk_type: ChunkEnum = Field(default=ChunkEnum.CONTENT, description="Type of chunk content")
chunk: str | dict | list = Field(default="", description="Chunk payload")
event: Literal["start", "delta", "end"] = Field(default="delta", description="Block update operation")
chunk: JsonValue = Field(default="", description="Delta or complete start/end snapshot")
done: bool = Field(default=False, description="Whether this is the final chunk")
status: StreamStatus | None = Field(default=None, description="Normalized execution state")
session_id: str | None = Field(default=None, description="Session identifier")
turn_id: str | None = Field(default=None, description="Agent invocation identifier")
message_id: str | None = Field(default=None, description="Model message identifier")
block_id: str | None = Field(default=None, description="Content block identifier")
tool_call_id: str | None = Field(default=None, description="Tool call identifier")
tool_call_name: str | None = Field(default=None, description="Tool call name")

View file

@ -2,7 +2,7 @@
from ..base_step import BaseStep
from ...components import R
from ...enumeration import ChunkEnum
from ...schema import collect_reply
@R.register("chat_step")
@ -33,18 +33,11 @@ this chat intentionally provides read-only tools. Reply in the user's language."
if session_id := self.context.get("session_id"):
wrapper_kwargs["resume"] = str(session_id)
parts: list[str] = []
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
if chunk.chunk_type == ChunkEnum.REPLY_END:
answer = "".join(parts).strip()
if answer:
chunk.metadata["answer"] = answer
await self.context.add_stream_chunk(chunk)
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
parts.append(chunk.chunk)
if chunk.session_id:
self.context.response.metadata["session_id"] = chunk.session_id
self.context.response.success = True
self.context.response.answer = "".join(parts).strip()
result = await collect_reply(
self.agent_wrapper.reply_stream(query, **wrapper_kwargs),
on_chunk=self.context.add_stream_chunk,
)
self.context.response.success = result["status"] == "completed" and self.context.stream_error is None
self.context.response.answer = result["result"]
self.context.response.metadata.update(result)
return self.context.response

View file

@ -42,18 +42,17 @@ class LLMDemoStep(BaseStep):
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
structured_content = result.get("structured_output")
text = (result.get("result") or "").strip()
text = result["result"].strip()
self.logger.info(f"[{self.name}] response: {text!r}")
self.context.response.success = True
self.context.response.answer = text
self.context.response.success = result["status"] == "completed"
self.context.response.answer = result["error"] or text
self.context.response.metadata.update(
{
**result,
"query": query,
"sys_prompt": sys_prompt,
"response": text,
"structured_output": structured_content,
},
)
return self.context.response

View file

@ -2,7 +2,7 @@
from ..base_step import BaseStep
from ...components import R
from ...enumeration import ChunkEnum
from ...schema import collect_reply
@R.register("stream_llm_demo_step")
@ -39,15 +39,19 @@ class StreamLLMDemoStep(BaseStep):
}
if self.context.stream:
text = await self._stream_reply(query, **wrapper_kwargs)
result = await collect_reply(
self.agent_wrapper.reply_stream(query, **wrapper_kwargs),
on_chunk=self.context.add_stream_chunk,
)
else:
result = await self.agent_wrapper.reply(query, **wrapper_kwargs)
text = (result.get("result") or "").strip()
text = result["result"].strip()
self.context.response.metadata.update(result)
self.logger.debug(f"[{self.name}] response: {text!r}")
self.context.response.success = True
self.context.response.answer = text
self.context.response.success = result["status"] == "completed" and self.context.stream_error is None
self.context.response.answer = result["error"] or text
self.context.response.metadata.update(
{
"query": query,
@ -56,19 +60,3 @@ class StreamLLMDemoStep(BaseStep):
},
)
return self.context.response
async def _stream_reply(self, query: str, **wrapper_kwargs) -> str:
"""Stream unified chunks to the context stream queue."""
assert self.context is not None
text_parts: list[str] = []
async for chunk in self.agent_wrapper.reply_stream(query, **wrapper_kwargs):
await self.context.add_stream_chunk(chunk)
if chunk.chunk_type == ChunkEnum.CONTENT and isinstance(chunk.chunk, str):
text_parts.append(chunk.chunk)
if chunk.session_id:
self.context.response.metadata["session_id"] = chunk.session_id
return "".join(text_parts).strip()

View file

@ -5,7 +5,7 @@ import zoneinfo
from agentscope.message import Msg
from ...schema import Response
from ...schema import AgentReply, Response
def now(timezone: str | None = None) -> datetime.datetime:
@ -31,17 +31,12 @@ def format_history(messages: list[Msg], include_timestamp: bool = True) -> str:
return "\n\n".join(lines) or "(empty)"
def agent_reply_result_text(reply_result: dict) -> str:
"""Return the final user-visible text block from an agent reply result."""
last_message = reply_result.get("last_message") or {}
content = last_message.get("content") if isinstance(last_message, dict) else None
if isinstance(content, list):
for block in reversed(content):
if isinstance(block, dict) and block.get("type") == "text":
text = str(block.get("text") or "").strip()
if text:
return text
return str(reply_result.get("result") or "").strip()
def agent_reply_result_text(reply_result: AgentReply) -> str:
"""Require a completed invocation and return its backend-neutral final text."""
status = reply_result["status"]
if status != "completed" or reply_result.get("error") is not None:
raise RuntimeError(reply_result.get("error") or f"Agent reply ended: {status}")
return reply_result["result"].strip()
def passthrough_response(step, skip_key: str) -> Response:

View file

@ -677,6 +677,7 @@ class AutoMemoryStep(BaseStep):
job_tools=self.create_tools if created else self.update_tools,
**reply_kwargs,
)
text = agent_reply_result_text(result)
self.logger.info(f"[{self.name}] agent done path={note_path} has_result={bool(result.get('result'))}")
if created:
@ -692,7 +693,7 @@ class AutoMemoryStep(BaseStep):
return
if note is None:
self.context.response.success = True
self.context.response.answer = agent_reply_result_text(result)
self.context.response.answer = text
self.context.response.metadata.update(
{"date": day, "path": None, "created": False, "modified": False, "n_messages": len(messages)},
)
@ -730,7 +731,7 @@ class AutoMemoryStep(BaseStep):
source_conversation = self._session_link(session_id)
self.context.response.success = True
self.context.response.answer = agent_reply_result_text(result)
self.context.response.answer = text
self.context.response.metadata.update(
{
"date": day,

View file

@ -120,6 +120,7 @@ class AutoTagStep(BaseStep):
job_tools=self.tools,
injected_job_kwargs=tool_context,
)
text = agent_reply_result_text(result)
path = self.workspace_path / target.path
metadata = dict(frontmatter.loads(path.read_text(encoding="utf-8")).metadata or {})
@ -139,7 +140,7 @@ class AutoTagStep(BaseStep):
)
if not response.success:
raise RuntimeError(str(response.answer))
return agent_reply_result_text(result)
return text
async def execute(self):
assert self.context is not None

View file

@ -414,6 +414,7 @@ class BaseAutoResourceStep(BaseStep):
}
try:
result = await self.agent_wrapper.reply(inputs, **agent_kwargs)
text = agent_reply_result_text(result)
except (Exception, asyncio.CancelledError):
self.context.response.success = False
try:
@ -430,7 +431,7 @@ class BaseAutoResourceStep(BaseStep):
metadata=note_metadata,
)
self.context.response.success = True
self.context.response.answer = agent_reply_result_text(result)
self.context.response.answer = text
if note_path is None:
self.logger.info(f"[{self.name}] done without note file_path={file_path} modified=False")
return None

View file

@ -146,6 +146,7 @@ class DreamIntegrateStep(BaseStep):
),
job_tools=list(_TOOLS),
)
raw_result = agent_reply_result_text(result)
except Exception as e: # noqa: BLE001
after = _snapshot_digest(workspace, digest_dir)
changed = _changed_digest_paths(before, after)
@ -177,7 +178,6 @@ class DreamIntegrateStep(BaseStep):
return
try:
raw_result = agent_reply_result_text(result)
outcome = IntegrateOutcome.model_validate(parse_structured_reply(raw_result))
if not self._valid_target(
workspace,

View file

@ -216,6 +216,7 @@ class ProactiveAgendaStep(BaseStep):
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
)
raw = agent_reply_result_text(result)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return self._fallback_agenda(carded_ids)
@ -224,7 +225,6 @@ class ProactiveAgendaStep(BaseStep):
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback agenda",
)
return self._fallback_agenda(carded_ids)
raw = agent_reply_result_text(result)
agenda_raw, suppressed_raw = parse_agenda_reply(raw)
allowed = set(carded_ids)

View file

@ -213,6 +213,7 @@ class ProactivePlanStep(BaseStep):
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
)
raw = agent_reply_result_text(result)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return {}
@ -221,7 +222,6 @@ class ProactivePlanStep(BaseStep):
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback cards",
)
return {}
raw = agent_reply_result_text(result)
cards_by_id: dict[str, dict] = {}
for card in parse_plan_reply(raw):
cid = str(card.get("topic_id") or "").strip()

View file

@ -208,12 +208,24 @@ async def call_action(
"""POST to /{action}; return parsed JSON (dict) for JSON endpoints, raw text for SSE."""
from ..components.client.http_client import HttpClient
pieces: list[str] = []
pieces: dict[str, str] = {}
result = None
async with HttpClient(host=host, port=port, timeout=timeout) as client:
async for chunk in client.stream_chunks(action, **kwargs):
payload = chunk.chunk
pieces.append(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
raw = "".join(pieces)
if chunk.chunk_type == "error" and chunk.metadata.get("will_retry") is not True:
raise RuntimeError(str(payload))
if chunk.chunk_type == "reply_end" and isinstance(payload, dict):
if payload["status"] != "completed":
raise RuntimeError(payload["error"] or f"Agent reply ended: {payload['status']}")
result = payload["result"]
continue
if payload is None or chunk.metadata.get("visibility") == "diagnostic":
continue
key = f"{chunk.chunk_type}:{chunk.block_id}" if chunk.block_id else str(len(pieces))
text = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
pieces[key] = pieces.get(key, "") + text if chunk.event == "delta" else text
raw = result if result is not None else "".join(pieces.values())
try:
return json.loads(raw)
except (ValueError, json.JSONDecodeError):

View file

@ -5,6 +5,7 @@ import type {
StreamChunk,
StreamPayload,
ToolBlock,
StreamStatus,
} from "./types";
const detailTypes = new Set(["think", "data", "approval", "usage"]);
@ -70,17 +71,42 @@ export async function readChatStream(
}
const payloadText = (payload: StreamPayload): string =>
typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
payload === null
? ""
: typeof payload === "string"
? payload
: JSON.stringify(payload, null, 2);
const appendError = (blocks: ChatBlock[], text: string): ChatBlock[] => {
const appendError = (
blocks: ChatBlock[],
text: string,
metadata?: Record<string, unknown>,
): ChatBlock[] => {
if (!text) return blocks;
const last = blocks.at(-1);
if (last?.type === "error") {
if (
last?.type === "error" &&
last.metadata?.will_retry === metadata?.will_retry
) {
return [...blocks.slice(0, -1), { ...last, text: last.text + text }];
}
return [...blocks, { id: `error:${blocks.length}`, type: "error", text }];
return [
...blocks,
{ id: `error:${blocks.length}`, type: "error", text, metadata },
];
};
const settled = (status: string): status is "done" | "error" | "interrupted" =>
["done", "error", "interrupted"].includes(status);
const blockStatus = (status?: StreamStatus | null) =>
status === "failed"
? "error"
: status === "interrupted"
? "interrupted"
: status === "waiting"
? "waiting"
: "done";
export function finishChatMessage(
message: ChatMessage,
error?: string,
@ -88,33 +114,35 @@ export function finishChatMessage(
): ChatMessage {
const failed =
Boolean(error) ||
(finishTools && message.blocks?.some((block) => block.type === "error"));
message.status === "failed" ||
message.blocks?.some(
(block) => block.type === "error" && block.metadata?.will_retry !== true,
);
const status = failed
? "failed"
: message.status === "running"
? "interrupted"
: message.status === "interrupted" || message.status === "waiting"
? message.status
: "completed";
let blocks = (message.blocks || []).map((block): ChatBlock => {
if (block.type === "content" || block.type === "error") return block;
// Claude emits reply_end before executing the tools in that message.
if (
!finishTools &&
block.type === "tool" &&
block.status !== "done" &&
block.status !== "error"
)
if (!finishTools && block.type === "tool" && !settled(block.status))
return block;
return {
...block,
status:
block.status === "error" || (failed && block.status !== "done")
? "error"
: "done",
status: settled(block.status) ? block.status : blockStatus(status),
expanded: false,
};
});
if (error) blocks = appendError(blocks, error);
return { ...message, blocks };
return { ...message, status, blocks };
}
function applyContent(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
if (chunk.chunk === null) return blocks;
const text = payloadText(chunk.chunk);
if (!text) return blocks;
if (!text && (!chunk.event || chunk.event === "delta")) return blocks;
const id = chunk.block_id || "";
const index = id
? blocks.findIndex(
@ -124,12 +152,21 @@ function applyContent(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
const current = blocks[index];
if (current?.type === "content" && (id || index === blocks.length - 1)) {
const next = [...blocks];
next[index] = { ...current, text: current.text + text };
next[index] = {
...current,
text: chunk.event && chunk.event !== "delta" ? text : current.text + text,
metadata: { ...current.metadata, ...chunk.metadata },
};
return next;
}
return [
...blocks,
{ id: `content:${id || blocks.length}`, type: "content", text },
{
id: `content:${id || blocks.length}`,
type: "content",
text,
metadata: chunk.metadata,
},
];
}
@ -160,10 +197,14 @@ function detailId(blocks: ChatBlock[], chunk: StreamChunk): string {
function applyDetail(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
const id = detailId(blocks, chunk);
const index = blocks.findIndex((block) => block.id === id);
const payloads = payloadText(chunk.chunk) ? [chunk.chunk] : [];
const payloads =
chunk.chunk !== null && (payloadText(chunk.chunk) || chunk.event === "end")
? [chunk.chunk]
: [];
const done =
chunk.chunk_type === "usage" &&
(chunk.input_tokens !== undefined || chunk.output_tokens !== undefined);
chunk.event === "end" ||
(chunk.chunk_type === "usage" &&
(chunk.input_tokens != null || chunk.output_tokens != null));
const type = detailTypes.has(chunk.chunk_type)
? (chunk.chunk_type as DetailBlock["type"])
: "unknown";
@ -175,8 +216,11 @@ function applyDetail(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
current.type !== "error"
? {
...current,
payloads: [...current.payloads, ...payloads],
status: done ? "done" : "streaming",
payloads:
chunk.event && chunk.event !== "delta" && chunk.chunk !== null
? payloads
: [...current.payloads, ...payloads],
status: done ? blockStatus(chunk.status) : "streaming",
expanded: current.expanded,
mediaType: chunk.media_type || current.mediaType,
inputTokens: chunk.input_tokens ?? current.inputTokens,
@ -188,11 +232,11 @@ function applyDetail(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
type,
sourceType: chunk.chunk_type,
payloads,
status: done ? "done" : "streaming",
status: done ? blockStatus(chunk.status) : "streaming",
expanded: true,
mediaType: chunk.media_type,
inputTokens: chunk.input_tokens,
outputTokens: chunk.output_tokens,
mediaType: chunk.media_type || undefined,
inputTokens: chunk.input_tokens ?? undefined,
outputTokens: chunk.output_tokens ?? undefined,
metadata: chunk.metadata,
};
if (index < 0) return [...blocks, nextBlock];
@ -203,72 +247,55 @@ function applyDetail(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
function applyTool(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
const last = blocks.at(-1);
const activeToolId =
last?.type === "tool" && last.status !== "done" && last.status !== "error"
? last.id.slice("tool:".length)
: undefined;
const toolId =
chunk.tool_call_id ||
chunk.block_id ||
activeToolId ||
`${chunk.chunk_type}:${blocks.length}`;
(last?.type === "tool" && !settled(last.status)
? last.id.slice("tool:".length)
: `${blocks.length}`);
const id = `tool:${toolId}`;
const index = blocks.findIndex(
(block) => block.type === "tool" && block.id === id,
);
const current = index >= 0 ? (blocks[index] as ToolBlock) : undefined;
const hasPayload = payloadText(chunk.chunk).length > 0;
const result = chunk.chunk_type === "tool_result";
const payload =
typeof chunk.chunk === "object" && !Array.isArray(chunk.chunk)
? chunk.chunk
: undefined;
const snapshot =
payload?.id === toolId && typeof payload.type === "string"
? payload
: undefined;
const claudeResult =
result && payload?.tool_use_id === toolId && "content" in payload
? payload
: undefined;
const state = String(
chunk.metadata?.state || snapshot?.status || "",
).toLowerCase();
const failed =
current?.status === "error" ||
claudeResult?.is_error === true ||
state.includes("error") ||
state.includes("fail") ||
state === "declined";
const previous =
(result ? current?.resultPayloads : current?.callPayloads) || [];
const payloads =
chunk.chunk === null
? previous
: chunk.event && chunk.event !== "delta"
? [chunk.chunk]
: [...previous, ...(payloadText(chunk.chunk) ? [chunk.chunk] : [])];
// TOOL_CALL end finishes argument streaming; only TOOL_RESULT end finishes execution.
const completed =
failed ||
current?.status === "done" ||
claudeResult !== undefined ||
(state !== "" && state !== "inprogress");
chunk.status === "completed" ||
chunk.status === "failed" ||
chunk.status === "interrupted" ||
(result &&
chunk.event === "end" &&
chunk.status !== "running" &&
chunk.status !== "waiting");
const status =
current && (current.status === "error" || current.status === "interrupted")
? current.status
: completed
? blockStatus(chunk.status)
: current && settled(current.status)
? current.status
: chunk.status === "waiting"
? "waiting"
: result
? "running"
: "calling";
const nextBlock: ToolBlock = {
id,
type: "tool",
name: chunk.tool_call_name || current?.name || "ReMe tool",
callPayloads: result
? current?.callPayloads || []
: [
...(current?.callPayloads || []),
...(hasPayload ? [chunk.chunk] : []),
],
resultPayloads: result
? [
...(current?.resultPayloads || []),
...(hasPayload ? [chunk.chunk] : []),
]
: current?.resultPayloads || [],
status: failed
? "error"
: completed
? "done"
: result
? "running"
: "calling",
expanded: completed ? false : current?.expanded ?? true,
callPayloads: result ? current?.callPayloads || [] : payloads,
resultPayloads: result ? payloads : current?.resultPayloads || [],
status,
expanded: settled(status) ? false : current?.expanded ?? true,
mediaType: chunk.media_type || current?.mediaType,
metadata: { ...current?.metadata, ...chunk.metadata },
};
@ -278,16 +305,23 @@ function applyTool(blocks: ChatBlock[], chunk: StreamChunk): ChatBlock[] {
return next;
}
export function applyStreamChunk(
function reduceStreamChunk(
message: ChatMessage,
chunk: StreamChunk,
): ChatMessage {
if (chunk.chunk_type === "reply_end") {
const answer =
typeof chunk.metadata?.answer === "string" ? chunk.metadata.answer : "";
const hasContent = (message.blocks || []).some(
(block) => block.type === "content" && block.text.length > 0,
);
const outcome =
chunk.chunk &&
typeof chunk.chunk === "object" &&
!Array.isArray(chunk.chunk)
? (chunk.chunk as ChatMessage["reply"])
: undefined;
const answer = outcome?.result || "";
const hasContent = (message.blocks || [])
.filter((block) => block.type === "content")
.map((block) => block.text)
.join("")
.endsWith(answer);
const completed =
answer && !hasContent
? {
@ -302,18 +336,53 @@ export function applyStreamChunk(
],
}
: message;
return finishChatMessage(completed, undefined, { finishTools: false });
const failed =
chunk.status === "failed" &&
!(completed.blocks || []).some(
(block) =>
block.type === "error" && block.metadata?.will_retry !== true,
);
let blocks = failed
? appendError(
completed.blocks || [],
String(
outcome?.error ||
chunk.metadata?.error ||
chunk.metadata?.finished_reason ||
"failed",
),
)
: completed.blocks;
if (outcome?.structured_output != null) {
blocks = [
...(blocks || []),
{
id: `structured:${message.id}`,
type: "data",
sourceType: "structured_output",
payloads: [outcome.structured_output as StreamPayload],
status: "done",
expanded: false,
},
];
}
return finishChatMessage(
{
...completed,
blocks,
status: outcome?.status || chunk.status || undefined,
reply: outcome,
},
undefined,
{ finishTools: chunk.event === "end" },
);
}
if (chunk.chunk_type === "done" || chunk.done) {
return finishChatMessage(message);
}
if (chunk.chunk_type === "reply_start") return message;
if (
chunk.chunk_type === "data" &&
typeof chunk.metadata?.codex_method === "string" &&
chunk.metadata.codex_method.startsWith("hook/")
)
return message;
if (chunk.chunk_type === "reply_start")
return { ...message, status: "running" };
if (chunk.metadata?.visibility === "diagnostic") return message;
let blocks = message.blocks || [];
if (
@ -340,11 +409,21 @@ export function applyStreamChunk(
if (chunk.chunk_type === "error")
return {
...message,
blocks: appendError(blocks, payloadText(chunk.chunk)),
blocks: appendError(blocks, payloadText(chunk.chunk), chunk.metadata),
};
return { ...message, blocks: applyDetail(blocks, chunk) };
}
export function applyStreamChunk(
message: ChatMessage,
chunk: StreamChunk,
): ChatMessage {
return {
...reduceStreamChunk(message, chunk),
events: [...(message.events || []), chunk],
};
}
export function toggleChatBlock(
message: ChatMessage,
blockId: string,

View file

@ -89,6 +89,8 @@ const messages = {
streaming: "进行中",
completed: "已完成",
failed: "失败",
interrupted: "已中断",
waiting: "等待输入",
askWorkspace: "询问你的工作区…",
send: "发送",
composerHint: "Enter 发送 · Shift + Enter 换行",
@ -247,6 +249,8 @@ const messages = {
streaming: "Streaming",
completed: "Completed",
failed: "Failed",
interrupted: "Interrupted",
waiting: "Waiting for input",
askWorkspace: "Ask about your workspace…",
send: "Send",
composerHint: "Enter to send · Shift + Enter for a new line",

View file

@ -59,7 +59,19 @@ export function mergeSessionMessages(
},
];
for (const chunk of chunks) message = applyStreamChunk(message, chunk);
if (previous?.reply) {
message = applyStreamChunk(message, {
chunk_type: "reply_end",
event: "end",
chunk: previous.reply,
status: previous.reply.status,
done: false,
});
}
if (entry.completed) message = finishChatMessage(message);
if (previous?.events?.length) {
message.events = previous.events;
}
const failed = message.blocks?.some(
(block) =>
block.type === "error" ||

View file

@ -217,7 +217,8 @@ export const useWorkspaceStore = create<WorkspaceState>()(
applyStreamChunk(message, chunk),
);
const turnId =
chunk.chunk_type === "reply_start" && chunk.metadata?.turn_id;
chunk.chunk_type === "reply_start" &&
(chunk.turn_id || chunk.metadata?.turn_id);
if (typeof turnId === "string") {
const assistantIndex = updated.messages.findIndex(
(message) => message.id === messageId,

View file

@ -90,26 +90,58 @@ export type StreamChunkType =
| "error"
| "done";
export type StreamPayload = string | Record<string, unknown> | unknown[];
export type StreamPayload =
| string
| number
| boolean
| Record<string, unknown>
| unknown[]
| null;
export type StreamStatus =
| "running"
| "completed"
| "failed"
| "interrupted"
| "waiting";
export interface StreamChunk {
chunk_type: StreamChunkType | (string & {});
event?: "start" | "delta" | "end";
chunk: StreamPayload;
done: boolean;
session_id?: string;
block_id?: string;
tool_call_id?: string;
tool_call_name?: string;
media_type?: string;
input_tokens?: number;
output_tokens?: number;
status?: StreamStatus | null;
session_id?: string | null;
turn_id?: string | null;
message_id?: string | null;
block_id?: string | null;
tool_call_id?: string | null;
tool_call_name?: string | null;
media_type?: string | null;
input_tokens?: number | null;
output_tokens?: number | null;
metadata?: Record<string, unknown>;
}
export interface AgentReply {
session_id: string;
result: string;
structured_output: unknown;
usage: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
} | null;
status: Exclude<StreamStatus, "running">;
error: string | null;
raw: Record<string, unknown>;
events: StreamChunk[];
}
export interface ContentBlock {
id: string;
type: "content";
text: string;
metadata?: Record<string, unknown>;
}
export interface DetailBlock {
@ -117,7 +149,7 @@ export interface DetailBlock {
type: "think" | "data" | "approval" | "usage" | "unknown";
sourceType: string;
payloads: StreamPayload[];
status: "streaming" | "done" | "error";
status: "streaming" | "done" | "error" | "interrupted" | "waiting";
expanded: boolean;
mediaType?: string;
inputTokens?: number;
@ -131,7 +163,7 @@ export interface ToolBlock {
name: string;
callPayloads: StreamPayload[];
resultPayloads: StreamPayload[];
status: "calling" | "running" | "done" | "error";
status: "calling" | "running" | "done" | "error" | "interrupted" | "waiting";
expanded: boolean;
mediaType?: string;
metadata?: Record<string, unknown>;
@ -141,6 +173,7 @@ export interface ErrorBlock {
id: string;
type: "error";
text: string;
metadata?: Record<string, unknown>;
}
export type ChatBlock = ContentBlock | DetailBlock | ToolBlock | ErrorBlock;
@ -150,6 +183,9 @@ export interface ChatMessage {
role: "user" | "assistant";
content: string;
blocks?: ChatBlock[];
events?: StreamChunk[];
reply?: Omit<AgentReply, "events">;
status?: StreamStatus;
turnId?: string;
source?: "chat" | "proactive";
createdAt?: string | null;

View file

@ -79,7 +79,7 @@ function ChatBlockView({
block.status === "streaming" ||
block.status === "calling" ||
block.status === "running";
const failed = block.status === "error";
const failed = block.status === "error" || block.status === "interrupted";
const tool = block.type === "tool";
const call = tool ? formatStreamPayloads(block.callPayloads) : "";
const result = tool ? formatStreamPayloads(block.resultPayloads) : "";
@ -97,7 +97,17 @@ function ChatBlockView({
<ChunkStatus active={active} error={failed} />
<span>{tool ? block.name : detailLabel(block, t)}</span>
<small>
{t(failed ? "failed" : active ? "streaming" : "completed")}
{t(
block.status === "interrupted"
? "interrupted"
: block.status === "waiting"
? "waiting"
: failed
? "failed"
: active
? "streaming"
: "completed",
)}
</small>
</summary>
{tool ? (
@ -231,6 +241,7 @@ export default function Chat({
const assistant: ChatMessage = {
id: crypto.randomUUID(),
role: "assistant",
status: "running",
content: "",
blocks: [],
};
@ -295,6 +306,17 @@ export default function Chat({
}
/>
))}
{message.role === "assistant" &&
(message.status === "interrupted" ||
message.status === "waiting") && (
<p role="status">
{t(
message.status === "interrupted"
? "interrupted"
: "waiting",
)}
</p>
)}
{message.role === "assistant" &&
tab.streaming &&
!message.blocks?.some((block) => block.type !== "usage") && (

View file

@ -113,7 +113,7 @@ test("thinking finishes as each tool or answer phase starts", () => {
message,
chunk("tool_result", "00:11", {
tool_call_id: "clock",
metadata: { state: "completed" },
status: "completed",
}),
);
message = applyStreamChunk(
@ -192,7 +192,11 @@ test("reply completion restores the final answer when content deltas were missed
);
message = applyStreamChunk(
message,
chunk("reply_end", "", { metadata: { answer: "Final answer." } }),
chunk(
"reply_end",
{ result: "Final answer." },
{ event: "end", status: "completed" },
),
);
assert.equal(message.blocks.at(-1).type, "content");
@ -232,7 +236,7 @@ test("Codex hook notifications do not become chat data blocks", () => {
threadId: "thread-1",
turnId: "turn-1",
},
{ metadata: { codex_method: method } },
{ metadata: { codex_method: method, visibility: "diagnostic" } },
),
);
}
@ -281,6 +285,7 @@ test("Codex tool snapshots finish before the reply ends", () => {
{
tool_call_id: "tool-1",
tool_call_name: "search",
status: "running",
},
),
);
@ -307,6 +312,8 @@ test("Codex tool snapshots finish before the reply ends", () => {
{ id: "tool-1", type: "mcpToolCall", status: "completed" },
{
tool_call_id: "tool-1",
event: "end",
status: "completed",
},
),
);
@ -325,6 +332,8 @@ test("Codex failed and declined tools stay failed after reply completion", () =>
{
tool_call_id: "tool-1",
tool_call_name: "commandExecution",
event: "end",
status: "failed",
},
),
);
@ -362,7 +371,11 @@ test("Claude tools remain pending across message boundaries and report their act
chunk(
"tool_result",
{ tool_use_id: "tool-1", content: "Result", is_error },
{ tool_call_id: "tool-1" },
{
tool_call_id: "tool-1",
event: "end",
status: is_error ? "failed" : "completed",
},
),
);
assert.equal(message.blocks[0].status, is_error ? "error" : "done");
@ -378,7 +391,7 @@ test("Claude server tool results finish without an is_error field", () => {
chunk(
"tool_result",
{ tool_use_id: "server-1", content: { matches: 1 } },
{ tool_call_id: "server-1" },
{ tool_call_id: "server-1", event: "end", status: "completed" },
),
);
assert.equal(message.blocks[0].status, "done");
@ -406,7 +419,7 @@ test("a transport failure marks pending blocks failed and preserves completed ph
message,
chunk("tool_result", "", {
tool_call_id: "completed",
metadata: { state: "completed" },
status: "completed",
}),
);
message = applyStreamChunk(
@ -444,7 +457,7 @@ test("late tool output does not reopen a completed or failed execution", () => {
initial(),
chunk("tool_result", "Result", {
tool_call_id: "tool-1",
metadata: { state },
status: state,
}),
);
message = applyStreamChunk(
@ -640,3 +653,157 @@ test("a failed stream returns its error message", async () => {
assert.equal(error, "Connection lost");
});
test("authoritative text snapshots replace deltas, including an empty final text", () => {
let message = initial();
for (const event of [
chunk("content", "draft", { block_id: "answer" }),
chunk("content", "corrected", {
block_id: "answer",
event: "end",
metadata: { phase: "final_answer" },
}),
chunk("content", "corrected", { block_id: "answer", event: "end" }),
chunk("content", null, { block_id: "answer", event: "end" }),
])
message = applyStreamChunk(message, event);
assert.equal(message.blocks.length, 1);
assert.equal(message.blocks[0].text, "corrected");
assert.equal(message.blocks[0].metadata.phase, "final_answer");
message = applyStreamChunk(
message,
chunk("content", "", { block_id: "answer", event: "end" }),
);
assert.equal(message.blocks[0].text, "");
});
test("tool snapshots replace streamed output and argument completion keeps the tool pending", () => {
let message = applyStreamChunk(
initial(),
chunk("tool_call", '{"q":', { tool_call_id: "tool" }),
);
message = applyStreamChunk(
message,
chunk("tool_call", { q: "hello" }, { tool_call_id: "tool", event: "end" }),
);
assert.equal(message.blocks[0].status, "calling");
message = applyStreamChunk(
message,
chunk("tool_result", "partial", {
tool_call_id: "tool",
status: "running",
}),
);
message = applyStreamChunk(
message,
chunk(
"tool_result",
{ output: "complete" },
{ tool_call_id: "tool", event: "end", status: "completed" },
),
);
assert.deepEqual(message.blocks[0].callPayloads, [{ q: "hello" }]);
assert.deepEqual(message.blocks[0].resultPayloads, [{ output: "complete" }]);
assert.equal(message.blocks[0].status, "done");
});
test("retryable errors do not turn a recovered reply into a failure", () => {
let message = applyStreamChunk(
initial(),
chunk("error", "temporary", { metadata: { will_retry: true } }),
);
message = applyStreamChunk(message, chunk("content", "recovered"));
message = applyStreamChunk(
message,
chunk("reply_end", null, { event: "end", status: "completed" }),
);
message = applyStreamChunk(message, chunk("done", "", { done: true }));
assert.equal(message.status, "completed");
assert.equal(message.blocks[0].metadata.will_retry, true);
});
test("reply interruption and input waits survive transport completion", () => {
for (const status of ["interrupted", "waiting"]) {
let message = applyStreamChunk(
initial(),
chunk("think", "working", { block_id: "thought" }),
);
message = applyStreamChunk(
message,
chunk("tool_call", null, { tool_call_id: "tool" }),
);
message = applyStreamChunk(
message,
chunk("reply_end", null, { event: "end", status }),
);
message = applyStreamChunk(message, chunk("done", "", { done: true }));
assert.equal(message.status, status);
assert.equal(message.blocks[0].status, "done");
assert.equal(message.blocks[1].status, status);
}
});
test("terminal outcomes retain raw data, scalar structured output and diagnostic events", () => {
const outcome = {
session_id: "session",
result: "final answer",
structured_output: false,
usage: null,
status: "waiting",
error: null,
raw: { cache: 7, signature: "signed", future: [false, null] },
};
const events = [
chunk("reply_start", null, { event: "start", status: "running" }),
chunk("data", null, {
metadata: { visibility: "diagnostic", native: { future: [false, null] } },
}),
chunk("content", "checking", { block_id: "commentary" }),
chunk("reply_end", outcome, { event: "end", status: "waiting" }),
chunk("done", "", { done: true }),
];
const message = events.reduce(applyStreamChunk, initial());
assert.deepEqual(message.events, events);
assert.deepEqual(message.reply, outcome);
assert.equal(message.status, "waiting");
assert.deepEqual(
message.blocks.map((block) => block.type),
["content", "content", "data"],
);
assert.equal(message.blocks[1].text, "final answer");
assert.deepEqual(message.blocks[2].payloads, [false]);
assert.equal(formatStreamPayloads(message.blocks[2].payloads), "false");
const replayed = JSON.parse(JSON.stringify(message.events)).reduce(
applyStreamChunk,
initial(),
);
assert.deepEqual(replayed, message);
});
test("null usage fields do not falsely settle an in-progress detail block", () => {
const message = applyStreamChunk(
initial(),
chunk("usage", null, { input_tokens: null, output_tokens: null }),
);
assert.equal(message.blocks[0].status, "streaming");
assert.equal(message.blocks[0].inputTokens, undefined);
});
test("stopping a running invocation preserves interruption rather than implying success", () => {
let message = applyStreamChunk(
initial(),
chunk("reply_start", null, { status: "running" }),
);
message = applyStreamChunk(
message,
chunk("tool_call", "{}", { tool_call_id: "pending" }),
);
const stopped = finishChatMessage(message);
assert.equal(stopped.status, "interrupted");
assert.equal(stopped.blocks[0].status, "interrupted");
assert.deepEqual(stopped.events, message.events);
assert.equal(
stopped.blocks.some((block) => block.type === "error"),
false,
);
});

View file

@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyStreamChunk } from "../app/chat-stream.ts";
import { mergeSessionMessages, mergeSessionTab } from "../app/session-sync.ts";
const entry = (id, role, content, extra = {}) => ({
@ -164,6 +165,8 @@ test("history polling preserves running tools until a native completion snapshot
done: false,
tool_call_id: "tool-1",
tool_call_name: "search",
event: "end",
status: status === "inProgress" ? "running" : status,
});
const active = snapshot([
entry("a1", "assistant", "", {
@ -192,6 +195,8 @@ test("native history repairs interrupted tools and restores missed failures", ()
chunk: { id: "tool-1", type: "commandExecution", status },
done: false,
tool_call_id: "tool-1",
event: "end",
status,
},
],
}),
@ -209,3 +214,86 @@ test("native history repairs interrupted tools and restores missed failures", ()
const failed = mergeSessionMessages(recovered, data("failed"));
assert.equal(failed[0].blocks[0].status, "error");
});
test("history refresh preserves the exact live event trace and structured outcome", () => {
const streamed = turn("temporary-assistant", "B");
streamed.events = [
{ chunk_type: "data", chunk: { signature: "signed" }, done: false },
];
streamed.reply = {
session_id: "session-1",
usage: null,
status: "completed",
error: null,
result: "B",
structured_output: false,
raw: { signature: "signed" },
};
const result = mergeSessionMessages(
[streamed],
snapshot([entry("a1", "assistant", "B")]),
);
assert.deepEqual(result[0].events, streamed.events);
assert.deepEqual(result[0].reply, streamed.reply);
});
test("native completion cannot overwrite a collected application-level terminal outcome", () => {
for (const status of ["completed", "failed", "interrupted", "waiting"]) {
const outcome = {
session_id: "session-1",
result: "invalid JSON",
structured_output: false,
usage: null,
status,
error: status === "failed" ? "Codex returned invalid JSON" : null,
raw: { status: "completed", final_response: "invalid JSON" },
};
let live = { ...turn("temporary-assistant", "invalid JSON"), blocks: [] };
const chunks = [
{
chunk_type: "content",
event: "end",
block_id: "answer",
chunk: "invalid JSON",
done: false,
},
{
chunk_type: "reply_end",
event: "end",
status,
chunk: outcome,
done: false,
},
];
for (const chunk of chunks) live = applyStreamChunk(live, chunk);
const data = snapshot([
entry("a1", "assistant", "invalid JSON", {
chunks: [
chunks[0],
{
chunk_type: "reply_end",
event: "end",
status: "completed",
chunk: null,
done: false,
},
],
}),
]);
const restored = mergeSessionMessages([live], data);
assert.equal(restored[0].status, status);
assert.equal(restored[0].reply.status, status);
assert.deepEqual(restored[0].events, live.events);
assert.deepEqual(
restored[0].blocks.filter((block) => block.type === "data")[0].payloads,
[false],
);
assert.deepEqual(
restored[0].blocks
.filter((block) => block.type === "error")
.map((block) => block.text),
status === "failed" ? [outcome.error] : [],
);
assert.equal(mergeSessionMessages(restored, data), restored);
}
});

View file

@ -7,6 +7,7 @@ environment or a .env file at the repo root. Hits the real LLM API.
import asyncio
import sys
from pathlib import Path
from uuid import uuid4
INTEGRATION_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(INTEGRATION_DIR))
@ -24,15 +25,16 @@ async def _run_session_persistence() -> None:
try:
wrapper = app.context.components[ComponentEnum.AGENT_WRAPPER]["default"]
sid = "test-persist-session"
sid = str(uuid4())
# First call: establish session
_, msg_1 = await wrapper.reply(
reply_1 = await wrapper.reply(
"My favorite color is blue. Remember that.",
session_id=sid,
system_prompt="You are a helpful assistant. Keep answers short.",
)
text_1 = (msg_1.get_text_content() or "").strip()
assert reply_1["status"] == "completed", reply_1["error"]
text_1 = reply_1["result"].strip()
print(f"\n[session_persist] reply 1: {text_1!r}")
assert text_1, "Empty first reply"
@ -42,13 +44,14 @@ async def _run_session_persistence() -> None:
assert len(files_after_1) == 1, f"Expected 1 session file, got {len(files_after_1)}"
assert sid in files_after_1[0].name
# Second call: same session_id — agent should have previous context
_, msg_2 = await wrapper.reply(
# Resume the returned session to retain its context.
reply_2 = await wrapper.reply(
"What is my favorite color?",
session_id=sid,
resume=sid,
system_prompt="You are a helpful assistant. Keep answers short.",
)
text_2 = (msg_2.get_text_content() or "").strip()
assert reply_2["status"] == "completed", reply_2["error"]
text_2 = reply_2["result"].strip()
print(f"[session_persist] reply 2: {text_2!r}")
assert "blue" in text_2.lower(), f"Agent should recall 'blue' from session context, got: {text_2!r}"
@ -64,7 +67,7 @@ async def _run_fork_session() -> None:
try:
wrapper = app.context.components[ComponentEnum.AGENT_WRAPPER]["default"]
sid = "test-fork-origin"
sid = str(uuid4())
# Establish original session
await wrapper.reply(
@ -76,13 +79,15 @@ async def _run_fork_session() -> None:
assert len(files_before_fork) == 1
# Fork the session
forked_sid, msg_fork = await wrapper.reply(
forked = await wrapper.reply(
"What is the secret number?",
session_id=sid,
resume=sid,
fork_session=True,
system_prompt="You are a helpful assistant. Keep answers short.",
)
text_fork = (msg_fork.get_text_content() or "").strip()
assert forked["status"] == "completed", forked["error"]
forked_sid = forked["session_id"]
text_fork = forked["result"].strip()
print(f"\n[fork_session] forked reply: {text_fork!r}")
assert "42" in text_fork, f"Forked session should recall '42', got: {text_fork!r}"
@ -104,31 +109,32 @@ async def _run_fork_session() -> None:
await env.close_all()
async def _run_no_session_id() -> None:
"""When session_id is empty, no session file should be created."""
async def _run_auto_session_id() -> None:
"""Omitting session_id creates and returns a persisted session."""
with workspace_env() as env:
app = await env.make_app()
try:
wrapper = app.context.components[ComponentEnum.AGENT_WRAPPER]["default"]
_, msg = await wrapper.reply(
reply = await wrapper.reply(
"Say hello.",
system_prompt="You are a helpful assistant. Keep answers short.",
)
text = (msg.get_text_content() or "").strip()
assert reply["status"] == "completed", reply["error"]
text = reply["result"].strip()
print(f"\n[no_session] reply: {text!r}")
assert text, "Empty reply"
files = env.session_state_files()
assert len(files) == 0, f"No session files should be created without session_id, found {files}"
assert len(files) == 1 and reply["session_id"] in files[0].name, files
print("✓ test_no_session_id passed")
print("✓ test_auto_session_id passed")
finally:
await env.close_all()
async def _run_all() -> None:
await _run_no_session_id()
await _run_auto_session_id()
await _run_session_persistence()
await _run_fork_session()

View file

@ -98,7 +98,7 @@ async def test_live_shared_codex_sessions_tools_streaming_and_close(tmp_path):
job_tools=["add_draft", "read_draft"],
tool_context_id="one",
)
assert "FIRST_OK" in original["last_message"]
assert "FIRST_OK" in original["result"]
resumed = await second.reply(
"Call read_draft and include its exact result.",
session_id=original["session_id"],
@ -106,20 +106,20 @@ async def test_live_shared_codex_sessions_tools_streaming_and_close(tmp_path):
tool_context_id="one",
)
assert resumed["session_id"] == original["session_id"]
assert "STATE_OK" in resumed["last_message"]
assert "STATE_OK" in resumed["result"]
isolated = await second.reply(
"Call read_draft. If it is empty, reply ISOLATED_OK.",
job_tools=["read_draft"],
tool_context_id="two",
)
assert isolated["session_id"] != original["session_id"]
assert "ISOLATED_OK" in isolated["last_message"]
assert "STATE_OK" not in isolated["last_message"]
assert "ISOLATED_OK" in isolated["result"]
assert "STATE_OK" not in isolated["result"]
forked = await first.reply("Reply FORK_OK.", resume=original["session_id"], fork_session=True)
assert forked["session_id"] != original["session_id"]
assert "FORK_OK" in forked["last_message"]
assert "FORK_OK" in forked["result"]
custom = await first.reply("Call only_custom and include its result.", job_tools=["only_custom"])
assert "CUSTOM_JOB_OK" in custom["last_message"]
assert "CUSTOM_JOB_OK" in custom["result"]
assert app.context.metadata["live_custom_calls"] >= 1
structured = await first.reply("Return marker STRUCTURED_OK.", output_schema=_StructuredResult)
assert structured["structured_output"] == {"marker": "STRUCTURED_OK"}
@ -128,7 +128,7 @@ async def test_live_shared_codex_sessions_tools_streaming_and_close(tmp_path):
"Read the reme_memory skill instructions and reply SKILL_OK. Do not execute its scripts.",
skills=["reme_memory"],
)
assert "SKILL_OK" in skill["last_message"]
assert "SKILL_OK" in skill["result"]
assert (workspace / ".agents/skills/reme_memory").is_symlink()
concurrent = await asyncio.gather(first.reply("Reply ONE."), second.reply("Reply TWO."))
assert concurrent[0]["session_id"] != concurrent[1]["session_id"]

View file

@ -32,7 +32,7 @@ class FakeAgentWrapper(BaseAgentWrapper):
async def reply(self, inputs, **_kwargs) -> dict:
"""Record and accept one text-processor request."""
self.inputs = inputs
return {"result": "ok"}
return self._reply_result(session_id="stub", result="ok", raw={})
class FlakyAgentWrapper(BaseAgentWrapper):
@ -47,7 +47,7 @@ class FlakyAgentWrapper(BaseAgentWrapper):
self.calls += 1
if self.calls == 1:
raise RuntimeError("text provider unavailable")
return {"result": "recovered"}
return self._reply_result(session_id="stub", result="recovered", raw={})
class FakeImageAgentWrapper(AsAgentWrapper):
@ -76,7 +76,7 @@ class FakeImageAgentWrapper(AsAgentWrapper):
if self.error is not None:
raise self.error
if not self.perform_write:
return {"result": str(self.content)}
return self._reply_result(session_id="stub", result=str(self.content), raw={})
assert isinstance(inputs, Msg)
assert kwargs.get("output_schema") is None
target = kwargs["injected_job_kwargs"]["_allowed_paths"]
@ -103,7 +103,7 @@ class FakeImageAgentWrapper(AsAgentWrapper):
)
if self.after_write_error is not None:
raise self.after_write_error
return {"result": "Saved image note", "tool_result": response}
return self._reply_result(session_id="stub", result="Saved image note", raw={"tool_result": response})
class FlakyImageAgentWrapper(FakeImageAgentWrapper):

View file

@ -0,0 +1,437 @@
"""Portable outcomes retain native result details across agent backends."""
# pylint: disable=redefined-outer-name
import asyncio
import json
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import AsyncMock
from agentscope.message import AssistantMsg, Usage
from agentscope.event import ReplyStartEvent, ReplyEndEvent
from agentscope.model import StructuredResponse
from agentscope.model._model_usage import ChatUsage
from agentscope.types import ErrorInfo
from claude_agent_sdk import DeferredToolUse, ResultMessage
import pytest
from openai_codex.generated.v2_all import AgentMessageThreadItem, ItemCompletedNotification
from reme.components.agent_wrapper import AsAgentWrapper, CcAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.utils import global_counter_get_all
from .test_codex_agent_wrapper import _Turn, _usage_event, system as _codex_system
system = _codex_system
def _agentscope(tmp_path, monkeypatch, message, model=None):
"""Use real SDK results with mocked model and persistence boundaries."""
wrapper = AsAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)), as_llm="")
async def reply_stream(_inputs, **_kwargs):
yield ReplyStartEvent(name="agent", session_id="session-1", reply_id="reply-1")
if message.finished_reason is not None:
yield ReplyEndEvent(session_id="session-1", reply_id="reply-1", finished_reason=message.finished_reason)
yield message
agent = SimpleNamespace(
state=SimpleNamespace(session_id="session-1", context=[message]),
reply_stream=reply_stream,
)
wrapper.as_llm = SimpleNamespace(model=model)
monkeypatch.setattr(wrapper, "_build_agent", AsyncMock(return_value=(agent, "hello")))
monkeypatch.setattr(wrapper, "_dump_state", AsyncMock())
return wrapper
@pytest.mark.asyncio
@pytest.mark.parametrize(
("reason", "status"),
[
("completed", "completed"),
("error", "failed"),
("exceed_max_iters", "failed"),
("interrupted", "interrupted"),
(None, "waiting"),
],
)
async def test_agentscope_outcome_preserves_native_message(tmp_path, monkeypatch, reason, status):
"""Native termination, content and cache counters survive portable normalization."""
message = AssistantMsg(
name="agent",
content="final answer",
finished_reason=reason,
usage=Usage(input_tokens=10, output_tokens=2, cache_input_tokens=7),
)
if reason == "error":
message.error = ErrorInfo(type="upstream", message="provider unavailable")
model = SimpleNamespace(generate_structured_output=AsyncMock(return_value=StructuredResponse(content={})))
wrapper = _agentscope(tmp_path, monkeypatch, message, model)
result = await wrapper.reply("hello", **({"output_schema": {}} if status != "completed" else {}))
assert set(result) == {"session_id", "result", "structured_output", "usage", "status", "error", "raw", "events"}
assert result["status"] == status
assert result["result"] == "final answer"
assert result["raw"]["message"] == message.model_dump(mode="json")
assert result["usage"] == {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}
assert result["raw"]["message"]["usage"]["cache_input_tokens"] == 7
assert result["error"] == (
"provider unavailable"
if reason == "error"
else "AgentScope reply ended: exceed_max_iters" if reason == "exceed_max_iters" else None
)
model.generate_structured_output.assert_not_awaited()
await _assert_roundtrip(result)
@pytest.mark.asyncio
@pytest.mark.parametrize("interrupted", [False, True])
async def test_agentscope_preserves_extra_structured_response(tmp_path, monkeypatch, interrupted):
"""The extra model call keeps its metadata and usage without overwriting the reply."""
message = AssistantMsg(
name="agent",
content="answer",
finished_reason="completed",
usage=Usage(input_tokens=10, output_tokens=2),
)
structured = StructuredResponse(
content={},
usage=ChatUsage(input_tokens=3, output_tokens=1, time=0.0, cache_input_tokens=2),
metadata={"provider": "extra details"},
finished_reason="interrupted" if interrupted else "completed",
)
model = SimpleNamespace(generate_structured_output=AsyncMock(return_value=structured))
wrapper = _agentscope(tmp_path, monkeypatch, message, model)
result = await wrapper.reply("hello", output_schema={})
assert result["status"] == ("interrupted" if interrupted else "completed")
assert result["structured_output"] == {}
assert result["usage"] == {"input_tokens": 13, "output_tokens": 3, "total_tokens": 16}
assert result["raw"]["message"] == message.model_dump(mode="json")
assert result["raw"]["structured_response"]["metadata"] == structured.metadata
assert result["raw"]["structured_response"]["usage"]["cache_input_tokens"] == 2
await _assert_roundtrip(result)
@pytest.mark.asyncio
async def test_agentscope_propagates_cancellation_even_if_sdk_returns_a_message(tmp_path, monkeypatch):
"""An SDK interruption fallback cannot turn caller cancellation into a normal return."""
message = AssistantMsg(
name="agent",
content="interrupted",
finished_reason="interrupted",
usage=Usage(input_tokens=10, output_tokens=2),
)
wrapper = _agentscope(tmp_path, monkeypatch, message)
agent = wrapper._build_agent.return_value[0] # pylint: disable=protected-access
entered = asyncio.Event()
async def native_reply(_inputs, **_kwargs):
entered.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
yield ReplyEndEvent(session_id="session-1", reply_id="reply-1", finished_reason="interrupted")
yield message
agent.reply_stream = native_reply
task = asyncio.create_task(wrapper.reply("hello"))
await asyncio.wait_for(entered.wait(), timeout=2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
wrapper._dump_state.assert_awaited_once_with(agent.state) # pylint: disable=protected-access
counters = global_counter_get_all(wrapper.app_context.metadata, ["__token_counter", wrapper.name])
assert counters["children"]["total_tokens"]["value"] == 12
@pytest.mark.asyncio
@pytest.mark.parametrize(
("overrides", "status"),
[
({}, "completed"),
({"is_error": True, "subtype": "error_max_turns", "errors": ["turn limit"]}, "failed"),
({"terminal_reason": "aborted_tools"}, "interrupted"),
({"deferred_tool_use": DeferredToolUse(id="tool-1", name="external", input={})}, "waiting"),
],
)
async def test_claude_outcome_preserves_native_result(tmp_path, monkeypatch, overrides, status):
"""Fees, cache usage, permission denials and terminal reasons survive the common shape."""
message = ResultMessage(
subtype="success",
duration_ms=20,
duration_api_ms=10,
is_error=False,
num_turns=2,
session_id="session-1",
result="answer",
structured_output=False,
total_cost_usd=0.01,
usage={"input_tokens": 10, "output_tokens": 2, "cache_read_input_tokens": 7},
permission_denials=[{"tool_name": "Write", "tool_use_id": "tool-1"}],
)
message = replace(message, **overrides)
async def query(**_kwargs):
yield message
if message.is_error:
raise RuntimeError("Claude Code returned an error result: turn limit")
monkeypatch.setattr("claude_agent_sdk.query", query)
wrapper = CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
result = await wrapper.reply("hello")
assert set(result) == {"session_id", "result", "structured_output", "usage", "status", "error", "raw", "events"}
assert result["status"] == status
assert result["structured_output"] is False
assert result["error"] == ("turn limit" if status == "failed" else None)
assert result["raw"]["total_cost_usd"] == 0.01
assert result["raw"]["usage"]["cache_read_input_tokens"] == 7
assert result["raw"]["permission_denials"] == message.permission_denials
assert result["raw"]["terminal_reason"] == message.terminal_reason
await _assert_roundtrip(result)
@pytest.mark.asyncio
@pytest.mark.parametrize("cancelled", [False, True])
async def test_claude_does_not_hide_transport_errors_or_cancellation(tmp_path, monkeypatch, cancelled):
"""An unrelated failure after a result is still an exception, including cancellation."""
async def query(**_kwargs):
yield ResultMessage(
subtype="error_during_execution",
duration_ms=1,
duration_api_ms=1,
is_error=True,
num_turns=1,
session_id="session-1",
errors=["native error"],
)
if cancelled:
raise asyncio.CancelledError()
raise RuntimeError("transport disconnected")
monkeypatch.setattr("claude_agent_sdk.query", query)
wrapper = CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
with pytest.raises(asyncio.CancelledError if cancelled else RuntimeError, match=None if cancelled else "transport"):
await wrapper.reply("hello")
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted"])
async def test_reply_preserves_native_outcomes_items_and_usage(system, monkeypatch, status):
"""Failed and interrupted turns retain completed items and reported usage."""
from openai_codex.generated.v2_all import CommandExecutionThreadItem, TurnError, TurnStatus
first, _, _, _ = system
original = _Turn.stream
command = CommandExecutionThreadItem(
type="commandExecution",
id="command-1",
command="pwd",
command_actions=[],
source="agent",
cwd="/tmp",
status="completed",
aggregated_output="/tmp",
exit_code=0,
)
async def stream(turn):
yield _usage_event((10, 2), (10, 2), turn_id=turn.id)
yield SimpleNamespace(
method="item/completed",
payload=ItemCompletedNotification(threadId=turn.thread_id, turnId=turn.id, item=command, completedAtMs=0),
)
async for event in original(turn):
if event.method == "turn/completed":
event.payload.turn.status = TurnStatus(status)
if status == "failed":
event.payload.turn.error = TurnError(message="native failure", additional_details="details")
yield event
monkeypatch.setattr(_Turn, "stream", stream)
result = await first.reply("answer")
assert set(result) == {"session_id", "result", "structured_output", "usage", "status", "error", "raw", "events"}
assert result["status"] == status
assert result["result"] == "answer"
assert result["usage"] == {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}
assert result["error"] == ("native failure" if status == "failed" else None)
assert result["raw"]["status"] == status
assert result["raw"]["items"][0]["aggregatedOutput"] == "/tmp"
assert result["raw"]["usage"]["total"]["inputTokens"] == 10
if status == "failed":
assert result["raw"]["error"]["additionalDetails"] == "details"
await _assert_roundtrip(result)
@pytest.mark.asyncio
async def test_reply_selects_final_answer_and_preserves_other_messages(system, monkeypatch):
"""Codex message phases select final text without discarding other messages."""
first, _, _, _ = system
original = _Turn.stream
async def stream(turn):
for index, phase in enumerate((None, "final_answer", "commentary")):
yield SimpleNamespace(
method="item/completed",
payload=ItemCompletedNotification(
threadId=turn.thread_id,
turnId=turn.id,
completedAtMs=0,
item=AgentMessageThreadItem(type="agentMessage", id=f"item-{index}", text=str(phase), phase=phase),
),
)
async for event in original(turn):
yield event
monkeypatch.setattr(_Turn, "stream", stream)
result = await first.reply("legacy fallback")
assert result["result"] == "final_answer"
assert len(result["raw"]["items"]) == 4
@pytest.mark.asyncio
async def test_invalid_structured_reply_retains_native_result(system):
"""A JSON parsing failure preserves the provider's successful native turn."""
first, _, _, _ = system
result = await first.reply("invalid JSON", output_schema={"type": "object"})
assert result["status"] == "failed"
assert "invalid JSON" in result["error"]
assert result["result"] == "invalid JSON"
assert result["raw"]["status"] == "completed"
assert result["raw"]["final_response"] == "invalid JSON"
async def _assert_roundtrip(reply):
"""JSON storage, replay and collection preserve every event and outcome field."""
from reme.schema import collect_reply, replay_reply
stored = json.loads(json.dumps(reply))
replayed = [chunk async for chunk in replay_reply(stored)]
assert [chunk.model_dump(mode="json") for chunk in replayed] == stored["events"]
async def stream():
for chunk in replayed:
yield chunk
assert await collect_reply(stream()) == stored
replayed[0].metadata["mutated"] = True
assert "mutated" not in stored["events"][0]["metadata"]
@pytest.mark.asyncio
async def test_claude_preserves_unprojected_native_messages_and_final_outcome(tmp_path, monkeypatch):
"""Unknown blocks, signatures and tool results stay available beyond the display projection."""
from claude_agent_sdk import AssistantMessage, SystemMessage, ThinkingBlock, ToolResultBlock, UserMessage
native = [
SystemMessage(subtype="future_event", data={"future": [1, False, None]}),
AssistantMessage(content=[ThinkingBlock(thinking="check", signature="signature")], model="claude"),
UserMessage(content=[ToolResultBlock(tool_use_id="hidden", content=[{"type": "text", "text": "result"}])]),
ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session",
result="final",
structured_output=[],
),
]
calls = []
async def query(**kwargs):
calls.append(kwargs)
for message in native:
yield message
monkeypatch.setattr("claude_agent_sdk.query", query)
wrapper = CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
reply = await wrapper.reply("hello", output_schema={})
assert len(calls) == 1
assert [event["metadata"]["native"] for event in reply["events"] if "native" in event["metadata"]] == [
wrapper._serialize(message) for message in native # pylint: disable=protected-access
]
assert reply["structured_output"] == []
await _assert_roundtrip(reply)
@pytest.mark.asyncio
async def test_collector_preserves_empty_snapshots_unknown_fields_and_transport_marker():
"""The trace keeps block operations and future extension fields exactly, including DONE."""
from reme.enumeration import ChunkEnum
from reme.schema import StreamChunk, collect_reply
events = [
StreamChunk(chunk="draft", block_id="text", metadata={"native": {"signature": "signed"}}),
StreamChunk(chunk="", event="end", block_id="text", future_field={"x": [False, None]}),
StreamChunk(chunk=None, event="end", block_id="text"),
StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status="waiting",
chunk={
"session_id": "session",
"result": "",
"structured_output": False,
"usage": None,
"status": "waiting",
"error": None,
"raw": {"future": 1},
},
),
StreamChunk(chunk_type=ChunkEnum.DONE, done=True),
]
async def stream():
for event in events:
yield event
reply = await collect_reply(stream())
assert reply["events"] == [event.model_dump(mode="json") for event in events]
await _assert_roundtrip(reply)
@pytest.mark.asyncio
@pytest.mark.parametrize("duplicate", [False, True])
async def test_collector_rejects_missing_or_duplicate_outcomes(duplicate):
"""Transport termination cannot manufacture a successful reply."""
from reme.enumeration import ChunkEnum
from reme.schema import StreamChunk, collect_reply
async def stream():
if duplicate:
for _ in range(2):
yield StreamChunk(chunk_type=ChunkEnum.REPLY_END, chunk={"result": "answer"})
yield StreamChunk(chunk_type=ChunkEnum.DONE, done=True)
with pytest.raises(ValueError, match="reply_end"):
await collect_reply(stream())
@pytest.mark.asyncio
async def test_codex_terminal_snapshot_supplies_items_missing_from_notifications(system, monkeypatch):
"""The final native turn can complete or correct the item notifications."""
first, _, _, _ = system
original = _Turn.stream
async def stream(turn):
async for event in original(turn):
if event.method == "item/completed":
continue
if event.method == "turn/completed":
event.payload.turn.items[0].root.text = "authoritative final answer"
yield event
monkeypatch.setattr(_Turn, "stream", stream)
reply = await first.reply("draft")
assert reply["result"] == "authoritative final answer"
assert reply["raw"]["items"][0]["text"] == "authoritative final answer"
await _assert_roundtrip(reply)

View file

@ -0,0 +1,163 @@
"""Plugin answer steps consume the complete agent outcome in both return modes."""
# pylint: disable=protected-access
import asyncio
from importlib import import_module
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from reme.components.agent_wrapper import BaseAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.enumeration import ChunkEnum
from reme.schema import StreamChunk
from reme.steps.index._dedup import _ToolContextDedupMixin
class _Agent(BaseAgentWrapper):
"""Emit a text delta followed by its authoritative final snapshot."""
def __init__(self, status, **kwargs):
super().__init__(**kwargs)
self.result = self._reply_result(
session_id="session",
result="answer",
raw={"signature": "signed"},
status=status,
error="provider failed" if status == "failed" else None,
)
self.chunks = [
StreamChunk(chunk="answer", block_id="text", turn_id="turn"),
StreamChunk(chunk="answer", event="end", block_id="text", turn_id="turn"),
StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status=status,
session_id="session",
turn_id="turn",
chunk={key: value for key, value in self.result.items() if key != "events"},
),
]
def _add_tool_context(self, kwargs):
if "tool_context_id" not in kwargs:
return
self.app_context.metadata.setdefault(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, {})[
kwargs["tool_context_id"]
] = {"query": "question"}
async def reply(self, inputs, **kwargs):
"""Return the same outcome as the stream."""
self._add_tool_context(kwargs)
return self.result
async def reply_stream(self, inputs, **kwargs):
"""Yield original chunks, including IDs and snapshot operations."""
self._add_tool_context(kwargs)
for chunk in self.chunks:
yield chunk
@pytest.mark.asyncio
@pytest.mark.parametrize(("plugin", "class_name"), [("lme", "LmeAgenticAnswerStep"), ("beam", "BeamAgenticAnswerStep")])
@pytest.mark.parametrize("streaming", [False, True])
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted", "waiting"])
async def test_answer_plugins_preserve_outcome_and_stream_chunks(
tmp_path,
monkeypatch,
plugin,
class_name,
streaming,
status,
):
"""Final text is not doubled, incomplete replies fail, and metadata survives forwarding."""
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "plugins" / plugin / "src"))
step_type = getattr(import_module(f"reme_{plugin}.agentic_answer"), class_name)
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _Agent(status, app_context=context)
queue = asyncio.Queue() if streaming else None
response = await step_type(app_context=context, agent_wrapper=wrapper)(query="question", stream_queue=queue)
assert response.answer == ("provider failed" if status == "failed" else "answer")
assert response.success is (status == "completed")
assert response.metadata["status"] == status
assert response.metadata["raw"] == wrapper.result["raw"]
assert not context.metadata[_ToolContextDedupMixin.TOOL_CONTEXTS_KEY]
if streaming:
assert [queue.get_nowait() for _ in wrapper.chunks] == wrapper.chunks
assert response.metadata["events"] == [chunk.model_dump(mode="json") for chunk in wrapper.chunks]
@pytest.mark.asyncio
@pytest.mark.parametrize("plugin", ["lme", "beam"])
async def test_stream_failure_releases_temporary_query_state(tmp_path, monkeypatch, plugin):
"""A failed collector/transport leaves no query dedup state alive."""
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "plugins" / plugin / "src"))
step_type = getattr(import_module(f"reme_{plugin}.agentic_answer"), f"{plugin.title()}AgenticAnswerStep")
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _Agent("completed", app_context=context)
async def stream(_inputs, **kwargs):
wrapper._add_tool_context(kwargs)
yield wrapper.chunks[0]
raise RuntimeError("transport disconnected")
monkeypatch.setattr(wrapper, "reply_stream", stream)
queue = asyncio.Queue()
with pytest.raises(RuntimeError, match="transport disconnected"):
await step_type(app_context=context, agent_wrapper=wrapper)(query="question", stream_queue=queue)
assert not context.metadata[_ToolContextDedupMixin.TOOL_CONTEXTS_KEY]
assert queue.get_nowait() == wrapper.chunks[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("plugin", ["lme", "beam"])
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted", "waiting"])
async def test_judges_do_not_score_incomplete_agent_replies(tmp_path, monkeypatch, plugin, status):
"""A parseable partial judgement cannot become a valid benchmark score."""
if plugin == "beam":
pytest.importorskip("scipy")
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "plugins" / f"{plugin}-judge" / "src"))
module = import_module(f"judge_{plugin}.llm_judge")
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _Agent(status, app_context=context)
wrapper.reply = AsyncMock(return_value=wrapper.result)
if plugin == "lme":
step = module.LmeAnswerJudgeStep(app_context=context, agent_wrapper=wrapper)
wrapper.result["result"] = "YES"
args = {"query": "question", "agent_answer": "answer", "golden_answer": "answer"}
else:
step = module.BeamRubricJudgeStep(app_context=context, agent_wrapper=wrapper)
wrapper.result["result"] = '{"score": 1.0, "reason": "correct"}'
args = {"llm_response": "answer", "rubric": ["correct answer"]}
if status == "completed":
assert (await step(**args)).success
else:
with pytest.raises(RuntimeError, match="provider failed" if status == "failed" else status):
await step(**args)
@pytest.mark.asyncio
@pytest.mark.parametrize("plugin", ["auto-fin", "daily_paper"])
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
async def test_structured_consumers_reject_incomplete_agent_outputs(tmp_path, monkeypatch, plugin, status):
"""A valid-looking structured payload still requires a completed native invocation."""
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "plugins" / plugin / "src"))
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _Agent(status, app_context=context)
wrapper.reply = AsyncMock(return_value=wrapper.result)
wrapper.result["structured_output"] = {"title": "report", "description": "summary", "body": "body"}
error = "provider failed" if status == "failed" else status
if plugin == "auto-fin":
module = import_module("reme_auto_fin.research")
schema = import_module("reme_auto_fin.schema").AutoFinReportOutput
step = module.AutoFinResearchStep(app_context=context, agent_wrapper=wrapper, prompt_dict={"test": "question"})
with pytest.raises(RuntimeError, match=error):
await step._reply("test", schema)
else:
module = import_module("reme_daily_paper.base")
schema = import_module("reme_daily_paper").DailyPaperMarkdownOutput
with pytest.raises(RuntimeError, match=error):
module.structured_output(wrapper.result, schema)

View file

@ -0,0 +1,270 @@
"""Regression coverage for information preserved across agent stream adapters."""
# pylint: disable=missing-function-docstring,protected-access
import asyncio
from types import SimpleNamespace
import pytest
from agentscope.event import DataBlockDeltaEvent, ReplyEndEvent, TextBlockEndEvent, TextBlockStartEvent
from claude_agent_sdk import AssistantMessage, ResultMessage, StreamEvent, TextBlock
from openai_codex.generated.v2_all import (
AgentMessageThreadItem,
ItemCompletedNotification,
PlanThreadItem,
ReasoningThreadItem,
WebSearchThreadItem,
)
from reme.components.agent_wrapper.as_agent_wrapper import AsAgentWrapper
from reme.components.agent_wrapper.cc_agent_wrapper import CcAgentWrapper
from reme.components.agent_wrapper.codex_agent_wrapper import CodexAgentWrapper
from reme.components.agent_wrapper.codex_session_history import item_chunks
from reme.components.application_context import ApplicationContext
from reme.components.client.http_client import HttpClient
from reme.components.runtime_context import RuntimeContext
from reme.enumeration import ChunkEnum
from reme.schema import StreamChunk
from reme.steps.common.chat import ChatStep
from reme.utils.common_utils import call_action
@pytest.mark.parametrize(
"item",
[
AgentMessageThreadItem(type="agentMessage", id="answer", text="corrected", phase="final_answer"),
PlanThreadItem(type="plan", id="plan", text="updated plan"),
ReasoningThreadItem(type="reasoning", id="thought", summary=["one", "two"], content=["raw"]),
WebSearchThreadItem(type="webSearch", id="search", query="ReMe"),
],
)
def test_codex_completed_items_match_history_and_retain_final_snapshots(item):
payload = ItemCompletedNotification(threadId="session", turnId="turn", item=item, completedAtMs=0)
chunks = CodexAgentWrapper._event_to_chunks(SimpleNamespace(method="item/completed", payload=payload), "session")
assert chunks == item_chunks(item, session_id="session", turn_id="turn")
assert chunks and all(chunk.event == "end" and chunk.turn_id == "turn" for chunk in chunks)
if item.type == "agentMessage":
assert chunks[0].chunk == "corrected"
assert chunks[0].metadata["phase"] == "final_answer"
elif item.type == "webSearch":
assert chunks[0].tool_call_id == "search"
assert chunks[0].status == "completed"
assert chunks[0].chunk["query"] == "ReMe"
elif item.type == "reasoning":
assert [chunk.block_id for chunk in chunks] == ["thought:summary:0", "thought:summary:1", "thought:content:0"]
assert [chunk.chunk for chunk in chunks] == ["one", "two", "raw"]
def test_codex_reasoning_deltas_correlate_with_their_final_sections():
chunks = []
for method, index in [("item/reasoning/summaryTextDelta", 1), ("item/reasoning/textDelta", 0)]:
payload = SimpleNamespace(
item_id="thought",
turn_id="turn",
delta="text",
summary_index=index,
content_index=index,
)
chunks.extend(CodexAgentWrapper._event_to_chunks(SimpleNamespace(method=method, payload=payload), "session"))
assert [chunk.block_id for chunk in chunks] == ["thought:summary:1", "thought:content:0"]
assert chunks[0].metadata["kind"] != chunks[1].metadata["kind"]
def test_agentscope_block_boundaries_final_correction_and_url_data():
start = AsAgentWrapper._event_to_chunk(TextBlockStartEvent(reply_id="reply", block_id="block"))
end = AsAgentWrapper._event_to_chunk(TextBlockEndEvent(reply_id="reply", block_id="block", text="corrected"))
url = AsAgentWrapper._event_to_chunk(
DataBlockDeltaEvent(
reply_id="reply",
block_id="image",
media_type="image/png",
url="https://example.com/a.png",
),
)
assert start.event == "start" and start.chunk is None
assert end.event == "end" and end.chunk == "corrected"
assert end.turn_id == "reply"
assert url.chunk == {"url": "https://example.com/a.png"}
assert url.media_type == "image/png"
interrupted = AsAgentWrapper._event_to_chunk(
ReplyEndEvent(session_id="session", reply_id="reply", finished_reason="interrupted"),
)
assert interrupted.status == "interrupted"
assert interrupted.metadata["finished_reason"] == "interrupted"
def test_claude_indices_are_scoped_by_message_and_signatures_are_retained():
states = {}
chunks = [
CcAgentWrapper._raw_event_to_chunk(
{"type": "content_block_start", "index": index, "content_block": {"type": "text", "text": ""}},
message_id="message",
block_states=states,
)
for index in [0, 1]
]
assert [chunk.block_id for chunk in chunks] == ["message:0", "message:1"]
signature = CcAgentWrapper._raw_event_to_chunk(
{"type": "content_block_delta", "index": 1, "delta": {"type": "signature_delta", "signature": "signed"}},
message_id="message",
block_states=states,
)
assert signature.chunk["delta"]["signature"] == "signed"
assert signature.chunk_type == ChunkEnum.DATA
unknown = CcAgentWrapper._raw_event_to_chunk(
{"type": "content_block_start", "index": 2, "content_block": {"type": "future_type", "value": 1}},
message_id="message",
block_states=states,
)
assert unknown.chunk["content_block"]["value"] == 1
@pytest.mark.asyncio
async def test_claude_multiple_model_messages_have_one_invocation_end(tmp_path, monkeypatch):
async def query(**_kwargs):
for message_id, text in [("first", "checking"), ("second", "answer")]:
for event in [
{"type": "message_start", "message": {"id": message_id}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
{"type": "content_block_stop", "index": 0},
{"type": "message_stop"},
]:
yield StreamEvent(uuid=message_id, session_id="session", event=event)
yield AssistantMessage(content=[TextBlock(text=text + "!")], model="claude", message_id=message_id)
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=2,
session_id="session",
)
monkeypatch.setattr("claude_agent_sdk.query", query)
wrapper = CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
chunks = [chunk async for chunk in wrapper.reply_stream("hello")]
assert sum(chunk.chunk_type == ChunkEnum.REPLY_START for chunk in chunks) == 1
assert sum(chunk.chunk_type == ChunkEnum.REPLY_END for chunk in chunks) == 1
assert chunks[-1].chunk_type == ChunkEnum.REPLY_END
assert chunks[-1].status == "completed"
assert len({chunk.turn_id for chunk in chunks}) == 1
snapshots = [
chunk
for chunk in chunks
if chunk.chunk_type == ChunkEnum.CONTENT and chunk.event == "end" and chunk.chunk is not None
]
assert [(chunk.block_id, chunk.chunk) for chunk in snapshots] == [("first:0", "checking!"), ("second:0", "answer!")]
@pytest.mark.asyncio
async def test_claude_interleaved_child_events_keep_their_own_block_state(tmp_path, monkeypatch):
async def query(**_kwargs):
for parent, event in [
(None, {"type": "message_start", "message": {"id": "parent"}}),
(None, {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
("tool", {"type": "message_start", "message": {"id": "child"}}),
("tool", {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
(None, {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "parent"}}),
("tool", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "child"}}),
]:
yield StreamEvent(uuid="event", session_id="session", parent_tool_use_id=parent, event=event)
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session",
)
monkeypatch.setattr("claude_agent_sdk.query", query)
wrapper = CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
chunks = [chunk async for chunk in wrapper.reply_stream("hello")]
deltas = [chunk for chunk in chunks if chunk.chunk_type == ChunkEnum.CONTENT and chunk.event == "delta"]
assert [(chunk.block_id, chunk.chunk) for chunk in deltas] == [("parent:0", "parent"), ("child:0", "child")]
assert deltas[1].metadata["parent_tool_use_id"] == "tool"
@pytest.mark.asyncio
async def test_chat_aggregates_snapshots_without_duplicate_text_and_records_terminal_failure(tmp_path, monkeypatch):
async def reply_stream(_self, _inputs, **_kwargs):
for event in [
StreamChunk(block_id="answer", chunk="draft"),
StreamChunk(block_id="answer", event="end", chunk="corrected"),
StreamChunk(block_id="answer", event="end", chunk="corrected"),
StreamChunk(block_id="removed", chunk="discard this"),
StreamChunk(block_id="removed", event="end", chunk=""),
StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status="failed",
metadata={"error": "upstream failed"},
chunk={
key: value
for key, value in _self._reply_result(
session_id="session",
result="corrected",
raw={},
status="failed",
error="upstream failed",
).items()
if key != "events"
},
),
]:
yield event
monkeypatch.setattr(AsAgentWrapper, "reply_stream", reply_stream)
context = ApplicationContext(workspace_dir=str(tmp_path))
agent = AsAgentWrapper(as_llm="", app_context=context)
runtime = RuntimeContext(query="hello", stream_queue=asyncio.Queue())
response = await ChatStep(agent_wrapper=agent, app_context=context)(runtime)
assert response.answer == "corrected"
assert not response.success
assert runtime.stream_error == "upstream failed"
@pytest.mark.asyncio
async def test_http_text_consumers_reconcile_snapshots_and_skip_null_or_diagnostic_events(monkeypatch):
async def stream(_self, _action, _payload):
for chunk in [
StreamChunk(block_id="answer", chunk="draft"),
StreamChunk(block_id="answer", event="end", chunk="corrected"),
StreamChunk(block_id="answer", event="end", chunk="corrected"),
StreamChunk(block_id="answer", event="end", chunk=None),
StreamChunk(chunk_type=ChunkEnum.DATA, chunk={"internal": True}, metadata={"visibility": "diagnostic"}),
]:
yield chunk
monkeypatch.setattr(HttpClient, "_iter_stream_chunks", stream)
assert await call_action("chat") == "corrected"
display = "".join([text async for text in HttpClient()._execute("chat", {})])
assert display == "draft\ncorrected"
@pytest.mark.asyncio
async def test_agentscope_closing_stream_records_reported_usage_and_closes_sdk(tmp_path, monkeypatch):
from agentscope.event import ModelCallEndEvent
from unittest.mock import AsyncMock
from reme.utils import global_counter_get_all
closed = []
async def native_stream(_inputs, **_kwargs):
try:
yield ModelCallEndEvent(reply_id="reply", input_tokens=3, output_tokens=2)
await asyncio.Event().wait()
finally:
closed.append(True)
agent = SimpleNamespace(state=SimpleNamespace(session_id="session"), reply_stream=native_stream)
wrapper = AsAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)), as_llm="")
monkeypatch.setattr(wrapper, "_build_agent", AsyncMock(return_value=(agent, "hello")))
monkeypatch.setattr(wrapper, "_dump_state", AsyncMock())
stream = wrapper.reply_stream("hello")
await anext(stream)
await stream.aclose()
assert closed == [True]
counters = global_counter_get_all(wrapper.app_context.metadata, ["__token_counter", wrapper.name])
assert counters["children"]["total_tokens"]["value"] == 5

View file

@ -118,7 +118,7 @@ class _ReplyAgent(BaseAgentWrapper):
self.on_reply()
if self.error:
raise self.error
return self.result
return {**self._reply_result(session_id="stub", result="", raw={}), **self.result}
class _SequenceAgent(BaseAgentWrapper):
@ -138,7 +138,7 @@ class _SequenceAgent(BaseAgentWrapper):
outcome = outcome()
if isinstance(outcome, Exception):
raise outcome
return outcome
return {**self._reply_result(session_id="stub", result="", raw={}), **outcome}
def test_scan_day_files_includes_only_markdown_day_files():
@ -541,7 +541,7 @@ async def test_integrate_failure_only_blocks_affected_sources(tmp_path, monkeypa
def integrate_good():
_touch(tmp_path / target, "integrated memory")
return {"result": json.dumps({"action": "CREATE", "target_path": target})}
return {"status": "completed", "result": json.dumps({"action": "CREATE", "target_path": target})}
agent = _SequenceAgent(integrate_good, {"result": "{}"}, {"result": "{}"})
app_context = ApplicationContext(workspace_dir=str(tmp_path))
@ -718,12 +718,12 @@ async def test_integrate_emits_actual_changes_once_across_units(tmp_path, monkey
def create():
_touch(tmp_path / created, "new")
return {"result": json.dumps({"action": "CREATE", "target_path": created})}
return {"status": "completed", "result": json.dumps({"action": "CREATE", "target_path": created})}
def update():
_touch(tmp_path / created, "new with more evidence")
_touch(tmp_path / updated, "updated existing memory")
return {"result": json.dumps({"action": "REFINE", "target_path": created})}
return {"status": "completed", "result": json.dumps({"action": "REFINE", "target_path": created})}
agent = _SequenceAgent(
create,
@ -818,7 +818,7 @@ async def test_dream_jobs_tag_outputs_and_preserve_checkpoint_results(
async def reply(_message, **kwargs):
if kwargs["job_tools"] == ["read"]:
return {"result": json.dumps({"units": [unit]})}
return {"status": "completed", "result": json.dumps({"units": [unit]})}
if "list_tags" in kwargs["job_tools"]:
injected = kwargs["injected_job_kwargs"]
path = injected["_allowed_paths"][0]
@ -831,15 +831,15 @@ async def test_dream_jobs_tag_outputs_and_preserve_checkpoint_results(
)
await FrontmatterUpdateStep(file_store=store)(update_context)
assert update_context.response.success
return {"result": "Tagged ReMe"}
return {"status": "completed", "result": "Tagged ReMe"}
for path in targets:
previous = (tmp_path / path).read_text(encoding="utf-8") if (tmp_path / path).exists() else ""
_touch(tmp_path / path, previous + "ReMe evidence\n")
if integration_failure == "exception":
raise RuntimeError("integration unavailable")
if integration_failure == "invalid_receipt":
return {"result": "{}"}
return {"result": json.dumps({"action": "CREATE", "target_path": targets[0]})}
return {"status": "completed", "result": "{}"}
return {"status": "completed", "result": json.dumps({"action": "CREATE", "target_path": targets[0]})}
agent = _ReplyAgent()
monkeypatch.setattr(agent, "reply", AsyncMock(side_effect=reply))
@ -1278,7 +1278,7 @@ class _BatchAwareAgent(BaseAgentWrapper):
}
for index in range(count)
)
return {"result": json.dumps({"units": units})}
return {"status": "completed", "result": json.dumps({"units": units})}
@pytest.mark.asyncio

View file

@ -67,14 +67,14 @@ async def test_cc_job_tags_created_updated_and_renamed_notes_only(tmp_path, monk
**injected,
)
assert result.success
return {"result": "Tagged ReMe"}
return {"status": "completed", "result": "Tagged ReMe"}
memory_calls.append(mode)
if mode == "create":
target = tmp_path / f"daily/{day}/note.md"
target.parent.mkdir(parents=True)
post = frontmatter.Post("ReMe memory", name="note", session_id=session_id, source_conversation=source)
elif mode == "unchanged":
return {"result": "No new facts"}
return {"status": "completed", "result": "No new facts"}
else:
target = tmp_path / kwargs["injected_job_kwargs"]["_allowed_paths"][0]
post = frontmatter.loads(target.read_text(encoding="utf-8"))
@ -82,7 +82,7 @@ async def test_cc_job_tags_created_updated_and_renamed_notes_only(tmp_path, monk
if mode == "rename":
post.metadata["name"] = "renamed"
target.write_text(frontmatter.dumps(post), encoding="utf-8")
return {"result": "Recorded memory"}
return {"status": "completed", "result": "Recorded memory"}
agent = AsyncMock(spec=BaseAgentWrapper)
agent.reply.side_effect = reply

View file

@ -69,7 +69,7 @@ def _saved_line(message):
def memory_setup(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
wrapper = AsAgentWrapper(backend="agentscope", as_llm="")
wrapper.reply = AsyncMock(return_value={"result": "ok"})
wrapper.reply = AsyncMock(return_value={"status": "completed", "result": "ok"})
store = LocalFileStore(embedding_store="")
app = SimpleNamespace(
registry=R,
@ -112,7 +112,7 @@ def test_only_include_images_is_exposed_and_disabled_by_default():
async def test_text_path_keeps_main_input_kwargs_metadata_and_jsonl(setup, monkeypatch, options, images):
step, _, path = setup
wrapper = CcAgentWrapper(backend="claude_code")
wrapper.reply = AsyncMock(return_value={"result": "ok"})
wrapper.reply = AsyncMock(return_value={"status": "completed", "result": "ok"})
wrapper.kwargs["context_config"] = {"max_image_num": 0}
step.kwargs["agent_wrapper"] = wrapper
message = _message(images=images)
@ -380,7 +380,7 @@ async def test_configured_backend_and_job_switch(tmp_path, monkeypatch, backend,
wrapper = app.context.components["agent_wrapper"]["default"]
assert isinstance(wrapper, AsAgentWrapper if backend == "agentscope" else CcAgentWrapper)
assert wrapper.backend == backend
wrapper.reply = AsyncMock(return_value={"result": "ok"})
wrapper.reply = AsyncMock(return_value={"status": "completed", "result": "ok"})
monkeypatch.setattr(AutoMemoryStep, "_list_session_note", AsyncMock(return_value=None))
job = app.context.jobs["auto_memory"]
await job.start()
@ -439,7 +439,7 @@ async def test_default_job_still_passes_memory_changes_to_auto_tag(setup, monkey
async def write_memory(*_args, **_kwargs):
_write_note(target)
return {"result": "Memory written."}
return {"status": "completed", "result": "Memory written."}
monkeypatch.setattr(AutoMemoryStep, "_list_session_note", find_note)
wrapper.reply.side_effect = write_memory
@ -502,7 +502,7 @@ async def test_image_links_survive_native_full_rewrite_and_keep_context(setup, m
# Native write replaces frontmatter as well as the body. Auto Memory
# must restore previous image sources without rewriting this body.
await writer(path=note_path, name="image-memory", description="Images", content=body)
return {"result": "Updated."}
return {"status": "completed", "result": "Updated."}
wrapper.reply.side_effect = full_rewrite
await _run(step, [first, second], include_images=True)

View file

@ -384,7 +384,7 @@ async def test_loose_root_text_updates_and_deletes_original_daily_card(routed, a
metadata={"source_resource": "[[resource/report.txt]]"},
)
assert response.success is True
return {"result": "Updated original report."}
return {"status": "completed", "result": "Updated original report."}
env.app_context.registry = R
step_cls = AutoResourceStep if routed else AutoTextResourceStep

View file

@ -42,7 +42,7 @@ class _TaggingWrapper(BaseAgentWrapper):
post = frontmatter.loads(target.read_text(encoding="utf-8"))
post.metadata[self.tag_key] = self.tags
target.write_text(frontmatter.dumps(post), encoding="utf-8")
return {"result": f"tagged {path}", "last_message": {}}
return {"result": f"tagged {path}", "status": "completed"}
def _write_note(path: Path) -> None:
@ -63,7 +63,7 @@ async def test_auto_tag_reports_missing_agent_write_without_inventing_empty_tags
_write_note(note)
original = note.read_bytes()
wrapper = _TaggingWrapper(tmp_path)
monkeypatch.setattr(wrapper, "reply", AsyncMock(return_value={"result": "", "last_message": {}}))
monkeypatch.setattr(wrapper, "reply", AsyncMock(return_value={"result": "", "status": "completed"}))
step = AutoTagStep(agent_wrapper=wrapper, file_store=_file_store())
response = await step(RuntimeContext(changes=[{"change": "added", "path": "memory/note.md"}]))

View file

@ -82,7 +82,7 @@ class _FakeAgentWrapper(BaseAgentWrapper):
self.kwargs = kwargs
if self.on_reply is not None:
self.on_reply(inputs, kwargs)
return {"result": "ok"}
return self._reply_result(session_id="stub", result="ok", raw={})
class _StepJob:

View file

@ -212,22 +212,9 @@ async def test_agentscope_structured_output_uses_model_tool_choice_policy(tmp_pa
async def generate_structured_output(self, **kwargs):
"""Return a successful structured response."""
observed.update(kwargs)
return SimpleNamespace(content={"ok": True}, usage=None)
from agentscope.model import StructuredResponse
class FakeMessage:
"""Minimal AgentScope reply message."""
usage = None
@staticmethod
def model_dump():
"""Return serialized message content."""
return {"content": "answer"}
@staticmethod
def get_text_content():
"""Return plain text content."""
return "answer"
return StructuredResponse(content={"ok": True})
class FakeAgent:
"""Minimal AgentScope agent."""
@ -235,14 +222,13 @@ async def test_agentscope_structured_output_uses_model_tool_choice_policy(tmp_pa
state = SimpleNamespace(session_id="session-1", context=["context"])
@staticmethod
async def observe(_inputs):
"""Accept an input message."""
return None
async def reply_stream(_inputs, **_kwargs):
"""Stream real SDK termination and message shapes."""
from agentscope.event import ReplyEndEvent
from agentscope.message import AssistantMsg
@staticmethod
async def reply():
"""Return the fake reply message."""
return FakeMessage()
yield ReplyEndEvent(session_id="session-1", reply_id="reply", finished_reason="completed")
yield AssistantMsg(name="agent", content="answer", finished_reason="completed")
wrapper = AsAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)), as_llm="")
wrapper.as_llm = SimpleNamespace(model=FakeModel())

View file

@ -351,7 +351,7 @@ async def test_compact_session_uses_claude_command(tmp_path, monkeypatch):
async def reply(inputs, **kwargs):
calls.append((inputs, kwargs))
return {"last_message": {"is_error": False}}
return {"status": "completed", "error": None}
monkeypatch.setattr(wrapper, "reply", reply)
@ -483,7 +483,9 @@ async def test_reply_stream_emits_one_reply_end_for_normal_sdk_lifecycle(tmp_pat
delta_usage = next(chunk for chunk in chunks if chunk.metadata.get("stop_reason") == "end_turn")
assert delta_usage.chunk_type == ChunkEnum.USAGE
assert delta_usage.output_tokens == 2
assert not recorded_usages
assert [usage.model_dump() for usage in recorded_usages] == [
{"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
]
@pytest.mark.asyncio
@ -597,17 +599,23 @@ async def test_reply_stream_reports_session_mirror_errors(tmp_path, monkeypatch)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"wrapper_factory",
[
_wrapper,
lambda tmp_path: AsAgentWrapper(as_llm="", app_context=ApplicationContext(workspace_dir=str(tmp_path))),
],
)
@pytest.mark.parametrize("schema", [{}, {"type": "object"}])
async def test_reply_stream_rejects_output_schema(tmp_path, wrapper_factory, schema):
"""Streaming wrappers reject structured-output schemas consistently."""
wrapper = wrapper_factory(tmp_path)
async def test_reply_stream_supports_output_schema(tmp_path, monkeypatch, schema):
"""Structured output uses the same execution path for both return modes."""
from claude_agent_sdk import ResultMessage
with pytest.raises(NotImplementedError, match="Structured output is not supported"):
await anext(wrapper.reply_stream("hello", output_schema=schema))
async def query(**kwargs):
assert kwargs["options"].output_format == {"type": "json_schema", "schema": schema}
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session",
structured_output={},
)
monkeypatch.setattr("claude_agent_sdk.query", query)
chunks = [chunk async for chunk in _wrapper(tmp_path).reply_stream("hello", output_schema=schema)]
assert chunks[-1].chunk["structured_output"] == {}

View file

@ -1,6 +1,7 @@
"""Shared Codex runtime, isolated conversations, and same-Application MCP tools."""
# pylint: disable=missing-class-docstring,missing-function-docstring,protected-access,redefined-outer-name
# pylint: disable=too-many-lines
import asyncio
import json
@ -17,6 +18,8 @@ from fastmcp.client import StreamableHttpTransport
from fastmcp.exceptions import ToolError
from openai_codex.generated.v2_all import (
AgentMessageThreadItem,
AgentMessageDeltaNotification,
TurnStartedNotification,
ItemCompletedNotification,
ThreadTokenUsage,
ThreadTokenUsageUpdatedNotification,
@ -70,14 +73,25 @@ class _Turn:
async def stream(self):
try:
yield SimpleNamespace(method="turn/started", payload=SimpleNamespace(turn=SimpleNamespace(id=self.id)))
yield SimpleNamespace(
method="turn/started",
payload=TurnStartedNotification(
threadId=self.thread_id,
turn=Turn(id=self.id, items=[], status="inProgress", error=None),
),
)
for event in self.thread.codex.usage_events:
yield event
if self.thread.codex.run_hook:
await self.thread.codex.run_hook(self.thread, self.inputs)
yield SimpleNamespace(
method="item/agentMessage/delta",
payload=SimpleNamespace(item_id="item", delta=self.inputs),
payload=AgentMessageDeltaNotification(
threadId=self.thread_id,
turnId=self.id,
itemId="item",
delta=str(self.inputs),
),
)
item = AgentMessageThreadItem(type="agentMessage", id="item", text=str(self.inputs))
yield SimpleNamespace(
@ -808,8 +822,8 @@ async def test_structured_reply_and_native_inputs(system):
inputs = [TextInput("describe"), LocalImageInput("image.png")]
await first.reply(inputs)
assert _Codex.instances[0].threads["thread-2"].inputs[0] is inputs
with pytest.raises(NotImplementedError):
await anext(first.reply_stream("no", output_schema=_StructuredModel))
chunks = [chunk async for chunk in first.reply_stream('{"ok": false}', output_schema=_StructuredModel)]
assert chunks[-1].chunk["structured_output"] == {"ok": False}
@pytest.mark.asyncio

View file

@ -6,6 +6,7 @@ from contextlib import chdir as _temp_chdir
import asyncio
import tempfile
import warnings
from unittest.mock import AsyncMock
import pytest
@ -13,7 +14,7 @@ from reme.components.agent_wrapper import BaseAgentWrapper
from reme.components.application_context import ApplicationContext
from reme.components.file_store import LocalFileStore
from reme.enumeration import ChunkEnum
from reme.schema import FileFrontMatter, FileLink, FileNode, StreamChunk, TraverseGraph
from reme.schema import FileFrontMatter, FileLink, FileNode, StreamChunk, TokenUsage, TraverseGraph
from reme.steps.common.add import AddStep
from reme.steps.common.health_check import _file_graph_status
from reme.steps.common.llm_demo import LLMDemoStep
@ -154,7 +155,7 @@ class _FakeAgentWrapper(BaseAgentWrapper):
async def reply(self, inputs, **kwargs) -> dict:
self.last_kwargs = kwargs
return {"result": "ok"}
return self._reply_result(session_id="session-1", result="ok", raw={})
async def reply_stream(self, inputs, **kwargs):
self.last_kwargs = kwargs
@ -174,6 +175,29 @@ async def test_llm_demo_always_registers_add_tool():
assert "job_tools" not in resp.metadata
@pytest.mark.asyncio
@pytest.mark.parametrize("step_class", [LLMDemoStep, StreamLLMDemoStep])
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted", "waiting"])
async def test_nonstream_demo_forwards_outcome_and_native_details(step_class, status):
"""HTTP/tool results retain native details and never report incomplete calls as successes."""
wrapper = _FakeAgentWrapper()
reply = wrapper._reply_result(
session_id="session-1",
result="partial or final",
status=status,
error="provider failure" if status == "failed" else None,
structured_output=False,
usage=TokenUsage(input_tokens=10, output_tokens=2),
raw={"native": "preserved"},
)
wrapper.reply = AsyncMock(return_value=reply)
response = await step_class()(query="hello", agent_wrapper=wrapper)
assert response.success is (status == "completed")
assert response.answer == ("provider failure" if status == "failed" else "partial or final")
assert all(response.metadata[key] == value for key, value in reply.items())
@pytest.mark.asyncio
async def test_stream_llm_demo_preserves_backend_chunk_metadata():
"""Forward retry flags, tool identities, usage, and completion status without rebuilding chunks."""
@ -183,7 +207,16 @@ async def test_stream_llm_demo_preserves_backend_chunk_metadata():
StreamChunk(chunk_type=ChunkEnum.TOOL_CALL, tool_call_id="call-1", tool_call_name="add", chunk={"a": 1}),
StreamChunk(chunk_type=ChunkEnum.CONTENT, session_id="session-1", block_id="text-1", chunk="hello"),
StreamChunk(chunk_type=ChunkEnum.USAGE, input_tokens=3, output_tokens=1),
StreamChunk(chunk_type=ChunkEnum.REPLY_END, metadata={"status": "completed"}),
StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status="completed",
chunk={
key: value
for key, value in wrapper._reply_result(session_id="session-1", result="hello", raw={}).items()
if key != "events"
},
),
]
queue = asyncio.Queue()
response = await StreamLLMDemoStep()(query="hello", stream_queue=queue, agent_wrapper=wrapper)

View file

@ -55,7 +55,7 @@ async def test_dream_reprocesses_concurrent_source_edits_after_restart(tmp_path,
def agent_for(app, blocked=False):
async def reply(message, **kwargs):
if "list_tags" in kwargs["job_tools"]:
return {"result": "No tags added by the controlled provider"}
return {"status": "completed", "result": "No tags added by the controlled provider"}
phase = "extract" if kwargs["job_tools"] == ["read"] else "integrate"
prompt = str(message)
calls.append((phase, prompt))
@ -66,11 +66,11 @@ async def test_dream_reprocesses_concurrent_source_edits_after_restart(tmp_path,
await release.wait()
if phase == "extract":
unit = {"name": "deployment", "bucket": "wiki", "summary": fact, "paths": [SOURCE]}
return {"result": json.dumps({"units": [unit]})}
return {"status": "completed", "result": json.dumps({"units": [unit]})}
action = "CORRECT" if (tmp_path / TARGET).exists() else "CREATE"
response = await app.run_job("write", path=TARGET, content=fact)
assert response.success, response.answer
return {"result": json.dumps({"action": action, "target_path": TARGET})}
return {"status": "completed", "result": json.dumps({"action": action, "target_path": TARGET})}
agent = AsyncMock(spec=BaseAgentWrapper)
agent.reply.side_effect = reply
@ -158,16 +158,16 @@ async def test_invalid_integration_preserves_retry_after_restart(tmp_path, older
async def reply(_message, **kwargs):
if "list_tags" in kwargs["job_tools"]:
return {"result": "No tags added by the controlled provider"}
return {"status": "completed", "result": "No tags added by the controlled provider"}
if kwargs["job_tools"] == ["read"]:
calls["extract"] += 1
unit = {"name": "deployment", "bucket": "wiki", "summary": OLD, "paths": [SOURCE, index]}
return {"result": json.dumps({"units": [unit]})}
return {"status": "completed", "result": json.dumps({"units": [unit]})}
calls["integrate"] += 1
if calls["integrate"] == write_attempt:
written = await app.run_job("write", path=TARGET, content=OLD)
assert written.success, written.answer
return {"result": "{}"}
return {"status": "completed", "result": "{}"}
agent = AsyncMock(spec=BaseAgentWrapper)
agent.reply.side_effect = reply

View file

@ -12,11 +12,12 @@ from reme.steps.evolve._evolve import agent_reply_result_text, format_history
from reme.steps.evolve.auto_memory import AutoMemoryStep, _sanitize_msg_for_save
def test_agent_reply_result_text_uses_last_text_block():
"""Agent reply text should be the last text block in the final message."""
def test_agent_reply_result_text_uses_portable_result():
"""Callers use final text selected by the wrapper, without inspecting native data."""
reply = {
"result": "intermediate text\nfinal text",
"last_message": {
"result": "final text",
"status": "completed",
"raw": {
"content": [
{"type": "thinking", "thinking": "hidden"},
{"type": "text", "text": "intermediate text"},
@ -29,13 +30,26 @@ def test_agent_reply_result_text_uses_last_text_block():
assert agent_reply_result_text(reply) == "final text"
def test_agent_reply_result_text_falls_back_to_result():
"""Agent reply text falls back to result when no text block exists."""
reply = {"result": " fallback text \n", "last_message": {"content": [{"type": "thinking"}]}}
def test_agent_reply_result_text_trims_result():
"""Text formatting does not depend on the presence of native content blocks."""
reply = {"result": " fallback text \n", "status": "completed", "raw": {}}
assert agent_reply_result_text(reply) == "fallback text"
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
def test_agent_reply_result_text_rejects_incomplete_replies(status):
"""Partial text must not turn an incomplete invocation into a success."""
with pytest.raises(RuntimeError, match=status):
agent_reply_result_text({"result": "partial text", "status": status, "error": None})
def test_agent_reply_result_text_preserves_error_message():
"""Provider failures remain actionable to the caller."""
with pytest.raises(RuntimeError, match="rate limit"):
agent_reply_result_text({"result": "", "status": "failed", "error": "rate limit"})
def test_sanitize_msg_for_save_drops_tool_results_and_base64_data():
"""Saved history should keep conversation context without tool output pollution."""
msg = Msg.model_validate(

View file

@ -44,7 +44,7 @@ def test_format_for_display_shows_metadata_when_requested():
@pytest.mark.asyncio
@pytest.mark.parametrize("will_retry", [True, False, None, "false"])
async def test_stream_retry_notifications_continue_but_terminal_errors_raise(will_retry):
async def test_raw_stream_preserves_errors_and_done_but_cli_raises_terminal_errors(will_retry):
"""A temporary Codex reconnect must not prevent CLI callers from receiving the answer."""
events = [
{"chunk_type": "error", "chunk": "upstream error", "metadata": {"will_retry": will_retry}},
@ -59,11 +59,95 @@ async def test_stream_retry_notifications_continue_but_terminal_errors_raise(wil
client = HttpClient()
client.client = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handle))
async with client:
chunks = [chunk async for chunk in client.stream_chunks("stream_llm_demo")]
assert [chunk.chunk_type for chunk in chunks] == [ChunkEnum.ERROR, ChunkEnum.CONTENT, ChunkEnum.DONE]
assert chunks[0].metadata == {"will_retry": will_retry}
assert chunks[1].chunk == "recovered answer"
if will_retry is True:
chunks = [chunk async for chunk in client.stream_chunks("stream_llm_demo")]
assert [chunk.chunk_type for chunk in chunks] == [ChunkEnum.ERROR, ChunkEnum.CONTENT]
assert chunks[0].metadata == {"will_retry": True}
assert chunks[-1].chunk == "recovered answer"
assert "recovered answer" in "".join([text async for text in client._execute("stream_llm_demo", {})])
else:
with pytest.raises(RuntimeError, match="upstream error"):
_ = [chunk async for chunk in client.stream_chunks("stream_llm_demo")]
_ = [text async for text in client._execute("stream_llm_demo", {})]
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted", "waiting"])
async def test_http_collection_and_replay_preserve_complete_outcomes(status):
"""Raw HTTP consumption preserves failures and native metadata all the way through DONE."""
from reme.schema import StreamChunk, collect_reply, replay_reply
events = [
StreamChunk(chunk_type=ChunkEnum.REPLY_START, status="running", chunk=None),
StreamChunk(chunk="draft", block_id="text"),
StreamChunk(chunk="corrected", event="end", block_id="text", metadata={"native": {"signature": "signed"}}),
StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status=status,
chunk={
"session_id": "session",
"result": "corrected",
"structured_output": [],
"usage": None,
"status": status,
"error": "failure" if status == "failed" else None,
"raw": {"cache": 7, "future": False},
},
),
StreamChunk(chunk_type=ChunkEnum.DONE, done=True),
]
if status == "failed":
events.insert(1, StreamChunk(chunk_type=ChunkEnum.ERROR, chunk="failure"))
body = "".join(f"data: {event.model_dump_json()}\n\n" for event in events)
def handle(_request):
return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=body)
client = HttpClient()
client.client = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handle))
async with client:
reply = await collect_reply(client.stream_chunks("chat"))
assert reply["status"] == status
assert reply["events"] == [event.model_dump(mode="json") for event in events]
assert await collect_reply(replay_reply(json.loads(json.dumps(reply)))) == reply
@pytest.mark.asyncio
@pytest.mark.parametrize("body", ["data: invalid\n\n", 'data: {"chunk":"partial"}\n\n'])
async def test_raw_http_stream_rejects_corruption_or_missing_done(body):
"""Broken streams must not silently lose events or become successful replies."""
def handle(_request):
return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=body)
client = HttpClient()
client.client = httpx.AsyncClient(base_url=client.base_url, transport=httpx.MockTransport(handle))
async with client:
with pytest.raises((ValueError, RuntimeError)):
_ = [chunk async for chunk in client.stream_chunks("chat")]
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
@pytest.mark.parametrize("error", [None, "provider refused the request"])
async def test_cli_reports_incomplete_terminal_outcomes(monkeypatch, status, error):
"""A partial answer followed by DONE must not hide an incomplete invocation."""
from reme.schema import StreamChunk
async def stream(_action, _payload):
yield StreamChunk(chunk="partial answer")
yield StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status=status,
chunk={"status": status, "error": error},
)
yield StreamChunk(chunk_type=ChunkEnum.DONE, done=True)
client = HttpClient()
monkeypatch.setattr(client, "_iter_stream_chunks", stream)
output = []
with pytest.raises(RuntimeError, match=error or status):
async for text in client._execute("chat", {}):
output.append(text)
assert output == ["partial answer"]

View file

@ -166,7 +166,7 @@ class _RecordingWrapper(BaseAgentWrapper):
async def reply(self, _inputs, **kwargs) -> dict:
self.calls.append(kwargs)
return {"session_id": "s-1", "last_message": {}, "result": "ok"}
return {"session_id": "s-1", "status": "completed", "result": "ok"}
async def _auto_memory_step(wrapper) -> AutoMemoryStep:

View file

@ -233,7 +233,22 @@ async def test_chat_error_chunks_match_execution_status_without_changing_stream(
async def reply_stream(self, inputs, **kwargs):
yield StreamChunk(chunk_type=ChunkEnum.ERROR, chunk="provider failed", metadata={"will_retry": will_retry})
yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk="partial or recovered")
yield StreamChunk(chunk_type=ChunkEnum.REPLY_END)
yield StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status="completed" if will_retry else "failed",
chunk={
key: value
for key, value in self._reply_result(
session_id="stub",
result="partial or recovered",
raw={},
status="completed" if will_retry else "failed",
error=None if will_retry else "provider failed",
).items()
if key != "events"
},
)
context.components["agent_wrapper"] = {"default": ErrorAgent()}
job = _job(context, StreamJob, track_execution=True, steps=[{"backend": "chat_step"}])

View file

@ -25,7 +25,7 @@ def fixture_judge(monkeypatch):
@pytest.mark.asyncio
@pytest.mark.parametrize("raw,expected", [("YES", "yes"), (" No, incorrect.", "no")])
async def test_valid_judgement_does_not_retry(judge, raw, expected):
judge.agent_wrapper.reply.return_value = {"result": raw}
judge.agent_wrapper.reply.return_value = {"result": raw, "status": "completed", "error": None}
response = await judge.execute()
assert response.success
assert response.answer == expected
@ -35,7 +35,10 @@ async def test_valid_judgement_does_not_retry(judge, raw, expected):
@pytest.mark.asyncio
async def test_invalid_output_retries_once_and_recovers(judge):
judge.agent_wrapper.reply.side_effect = [{"result": "mostly correct"}, {"result": "yes"}]
judge.agent_wrapper.reply.side_effect = [
{"result": "mostly correct", "status": "completed", "error": None},
{"result": "yes", "status": "completed", "error": None},
]
response = await judge.execute()
assert response.success and response.answer == "yes"
assert response.metadata["raw_answer_judgement"] == "yes"
@ -46,7 +49,7 @@ async def test_invalid_output_retries_once_and_recovers(judge):
@pytest.mark.asyncio
@pytest.mark.parametrize("raw", ["", "maybe", "yesterday", "correct"])
async def test_two_invalid_outputs_fail_without_a_score(judge, raw):
judge.agent_wrapper.reply.return_value = {"result": raw}
judge.agent_wrapper.reply.return_value = {"result": raw, "status": "completed", "error": None}
with pytest.raises(ValueError, match="invalid yes/no judgement after 2 attempts"):
await judge.execute()
assert judge.agent_wrapper.reply.await_count == 2
@ -59,3 +62,13 @@ async def test_transport_errors_are_not_invalid_output_retries(judge):
with pytest.raises(TimeoutError):
await judge.execute()
assert judge.agent_wrapper.reply.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["failed", "interrupted", "waiting"])
async def test_incomplete_replies_are_neither_scored_nor_retried(judge, status):
judge.agent_wrapper.reply.return_value = {"result": "yes", "status": status, "error": None}
with pytest.raises(RuntimeError, match=status):
await judge.execute()
assert judge.agent_wrapper.reply.await_count == 1
assert "answer_judgement" not in judge.context.response.metadata

View file

@ -111,7 +111,12 @@ def fixture_candidate(tmp_path, monkeypatch):
components={},
)
wrapper = AsAgentWrapper(backend="agentscope", as_llm="", app_context=app)
wrapper.reply = AsyncMock(return_value={"result": "answer", "session_id": "new", "usage": {"input_tokens": 7}})
wrapper.reply = AsyncMock(
return_value={
**wrapper._reply_result(session_id="new", result="answer", raw={}),
"usage": {"input_tokens": 7},
},
)
store = LocalFileStore(embedding_store="", app_context=app)
return memory, answer, app, wrapper, store, tmp_path
@ -320,16 +325,39 @@ async def test_image_only_pipeline_preserves_sessions_and_official_query(candida
@pytest.mark.asyncio
async def test_observation_preserves_native_reply_and_rejects_only_interrupted():
async def test_observation_preserves_completed_reply_and_rejects_incomplete():
from benchmark.memlens.run import observe_usage
result = {"result": "", "last_message": {"finished_reason": "exceed_max_iters", "error": None}}
result = {"result": "answer", "status": "completed", "error": None}
reply = AsyncMock(return_value=result)
wrapper = SimpleNamespace(reply=reply)
app = SimpleNamespace(context=SimpleNamespace(components={"agent_wrapper": {"default": wrapper}}))
observe_usage(app)
assert await wrapper.reply("Original input", react_config={"max_iters": 30}) is result
reply.assert_awaited_once_with("Original input", react_config={"max_iters": 30})
reply.return_value = {"last_message": {"finished_reason": "interrupted"}}
with pytest.raises(RuntimeError, match="interrupted"):
await wrapper.reply("Original input")
for status in ("interrupted", "failed", "waiting"):
reply.return_value = {"status": status, "error": None}
with pytest.raises(RuntimeError, match=status):
await wrapper.reply("Original input")
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted", "waiting"])
async def test_answer_preserves_native_usage_and_rejects_incomplete_outcomes(candidate, monkeypatch, status):
"""Standalone answer Jobs retain the canonical outcome and native cache counters."""
_, answer, app, wrapper, store, _ = candidate
native = {"message": {"usage": {"input_tokens": 7, "cache_input_tokens": 5}}}
result = wrapper._reply_result(
session_id="new",
result="answer",
raw=native,
status=status,
error="provider failed" if status == "failed" else None,
)
wrapper.reply.return_value = result
monkeypatch.setattr(wrapper, "_resolve_job_tools", lambda _names: [])
step = answer.MemLensAgenticAnswerStep(app_context=app, agent_wrapper=wrapper, file_store=store)
response = await step(query="question")
assert response.success is (status == "completed")
assert response.answer == ("provider failed" if status == "failed" else "answer")
assert all(response.metadata[key] == value for key, value in result.items())

View file

@ -114,7 +114,12 @@ def fixture_candidate(tmp_path, monkeypatch):
components={},
)
wrapper = AsAgentWrapper(backend="agentscope", as_llm="", app_context=app)
wrapper.reply = AsyncMock(return_value={"result": "answer", "session_id": "new", "usage": {"input_tokens": 7}})
wrapper.reply = AsyncMock(
return_value={
**wrapper._reply_result(session_id="new", result="answer", raw={}),
"usage": {"input_tokens": 7},
},
)
store = LocalFileStore(embedding_store="", app_context=app)
return name, memory, answer, prefix, app, wrapper, store, tmp_path
@ -183,7 +188,7 @@ async def test_candidate_and_judge_start_with_native_settings(tmp_path, monkeypa
monkeypatch.setattr(MobileMemAutoMemoryStep, "_list_session_note", AsyncMock(return_value=None))
wrappers = candidate_app.context.components["agent_wrapper"]
for wrapper in wrappers.values():
wrapper.reply = AsyncMock(return_value={"result": "answer"})
wrapper.reply = AsyncMock(return_value=wrapper._reply_result(session_id="new", result="answer", raw={}))
_, scene = mobile_scene
messages = mobilemem.session_messages(scene["sessions"][0], scene["image_root"])
for images, kwargs in ((True, {}), (False, {"include_images": False})):
@ -396,12 +401,35 @@ async def test_image_only_pipeline_keeps_all_sessions_and_gold_is_judge_only(mob
]
== 70
)
raw = {"last_message": {"finished_reason": "exceed_max_iters"}, "result": "native answer"}
raw = {"status": "completed", "error": None, "result": "native answer"}
native = AsyncMock(return_value=raw)
wrapper = SimpleNamespace(reply=native)
observe_completion(SimpleNamespace(context=SimpleNamespace(components={"agent_wrapper": {"bench": wrapper}})))
assert await wrapper.reply("unchanged input", max_iters=30) is raw
native.assert_awaited_once_with("unchanged input", max_iters=30)
native.return_value = {"last_message": {"finished_reason": "interrupted"}}
with pytest.raises(RuntimeError, match="interrupted"):
await wrapper.reply("unchanged input")
for status in ("interrupted", "failed", "waiting"):
native.return_value = {"status": status, "error": None}
with pytest.raises(RuntimeError, match=status):
await wrapper.reply("unchanged input")
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "failed", "interrupted", "waiting"])
async def test_answer_preserves_native_usage_and_rejects_incomplete_outcomes(candidate, monkeypatch, status):
"""Standalone answer Jobs retain the canonical outcome and native cache counters."""
_, _, answer, _, app, wrapper, store, _ = candidate
native = {"message": {"usage": {"input_tokens": 7, "cache_input_tokens": 5}}}
result = wrapper._reply_result(
session_id="new",
result="answer",
raw=native,
status=status,
error="provider failed" if status == "failed" else None,
)
wrapper.reply.return_value = result
monkeypatch.setattr(wrapper, "_resolve_job_tools", lambda _names: [])
step = answer.MobileMemAgenticAnswerStep(app_context=app, agent_wrapper=wrapper, file_store=store)
response = await step(query="question")
assert response.success is (status == "completed")
assert response.answer == ("provider failed" if status == "failed" else "answer")
assert all(response.metadata[key] == value for key, value in result.items())

View file

@ -147,11 +147,11 @@ class _AgentWrapper(BaseAgentWrapper):
return {
"session_id": "stub",
"result": text,
"last_message": {"content": [{"type": "text", "text": text}]},
"status": "completed",
}
class _DummyModel(ChatModelBase):
class _DummyModel(ChatModelBase): # pylint: disable=abstract-method
"""Placeholder model satisfying dream extract's llm_available check."""
def __init__(self):

View file

@ -123,105 +123,47 @@ def test_token_counter_is_a_per_agent_metric_tree(tmp_path):
@pytest.mark.asyncio
async def test_agentscope_reply_records_final_message_usage(tmp_path, monkeypatch):
"""AgentScope 2.0.4.post1 reports aggregate usage on the final message."""
@pytest.mark.parametrize("streaming", [False, True])
@pytest.mark.parametrize("reported", [False, True])
async def test_agentscope_records_usage_in_both_return_modes(tmp_path, monkeypatch, streaming, reported):
"""Both APIs use the same final native message and record usage exactly once."""
from agentscope.event import ReplyEndEvent
from agentscope.message import AssistantMsg, Usage
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = AsAgentWrapper(name="research", as_llm="", app_context=context)
message = SimpleNamespace(
# Match the AgentScope 2.0.4.post1 accumulation test: 10/20 + 5/8.
usage=SimpleNamespace(input_tokens=15, output_tokens=28),
model_dump=lambda: {"text": "answer"},
get_text_content=lambda: "answer",
message = AssistantMsg(
name="agent",
content="answer",
finished_reason="completed",
usage=Usage(input_tokens=15, output_tokens=28) if reported else None,
)
async def native_stream(_inputs, **_kwargs):
yield ReplyEndEvent(session_id="session-1", reply_id="reply", finished_reason="completed")
yield message
agent = SimpleNamespace(
state=SimpleNamespace(session_id="session-1", context=[message]),
observe=lambda _inputs: _async_none(),
reply=lambda: _async_value(message),
reply_stream=_unexpected_reply_stream,
reply_stream=native_stream,
)
async def build_agent(inputs, **_kwargs):
return agent, inputs
monkeypatch.setattr(wrapper, "_build_agent", build_agent)
monkeypatch.setattr(wrapper, "_dump_state", _async_none)
result = await wrapper.reply("hello")
assert result["usage"] == {"input_tokens": 15, "output_tokens": 28, "total_tokens": 43}
assert (
global_counter_get_all(context.metadata, ["__token_counter", "research"])["children"]["total_tokens"]["value"]
== 43
)
@pytest.mark.asyncio
async def test_agentscope_reply_without_usage_leaves_token_counters_unset(tmp_path, monkeypatch):
"""Replies without any usage information remain visibly unavailable."""
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = AsAgentWrapper(name="research", as_llm="", app_context=context)
message = SimpleNamespace(
usage=None,
model_dump=lambda: {"text": "answer"},
get_text_content=lambda: "answer",
)
agent = SimpleNamespace(
state=SimpleNamespace(session_id="session-1", context=[message]),
observe=lambda _inputs: _async_none(),
reply=lambda: _async_value(message),
reply_stream=_unexpected_reply_stream,
)
async def build_agent(inputs, **_kwargs):
return agent, inputs
monkeypatch.setattr(wrapper, "_build_agent", build_agent)
monkeypatch.setattr(wrapper, "_dump_state", _async_none)
result = await wrapper.reply("hello")
assert result["usage"] is None
assert "__token_counter" not in context.metadata
async def _async_none(*_args, **_kwargs):
return None
async def _async_value(value):
return value
def _unexpected_reply_stream(*_args, **_kwargs):
raise AssertionError("Non-streaming replies must use Agent.reply()")
@pytest.mark.asyncio
async def test_agentscope_stream_reply_does_not_record_token_usage(tmp_path, monkeypatch):
"""Only non-streaming AgentScope replies contribute to token accounting."""
context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = AsAgentWrapper(name="research", as_llm="", app_context=context)
class FakeAgent:
"""Minimal AgentScope stream double."""
state = type("State", (), {"session_id": "session-1"})()
async def reply_stream(self, inputs):
"""Yield no events for the supplied input."""
if inputs is None:
yield None
async def build_agent(inputs, **_kwargs):
"""Build the minimal stream double."""
return FakeAgent(), inputs
async def dump_state(_state):
"""Avoid durable state writes in this accounting test."""
return None
monkeypatch.setattr(wrapper, "_build_agent", build_agent)
monkeypatch.setattr(wrapper, "_dump_state", dump_state)
assert [chunk async for chunk in wrapper.reply_stream("hello")] == []
assert "__token_counter" not in context.metadata
if streaming:
chunks = [chunk async for chunk in wrapper.reply_stream("hello")]
result = chunks[-1].chunk
else:
result = await wrapper.reply("hello")
assert result["usage"] == ({"input_tokens": 15, "output_tokens": 28, "total_tokens": 43} if reported else None)
counters = global_counter_get_all(context.metadata, ["__token_counter", "research"])
if reported:
assert counters["children"]["total_tokens"]["value"] == 43
else:
assert counters is None

View file

@ -32,7 +32,17 @@ class _StreamingAgent(BaseAgentWrapper):
self.reply_kwargs = kwargs
yield StreamChunk(chunk_type=ChunkEnum.TOOL_CALL, chunk="{}", tool_call_id="tool-1", tool_call_name="search")
yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk="hello", session_id="session-new")
yield StreamChunk(chunk_type=ChunkEnum.REPLY_END, chunk="", session_id="session-new")
yield StreamChunk(
chunk_type=ChunkEnum.REPLY_END,
event="end",
status="completed",
session_id="session-new",
chunk={
key: value
for key, value in self._reply_result(session_id="session-new", result="hello", raw={}).items()
if key != "events"
},
)
@pytest.mark.asyncio
@ -125,7 +135,7 @@ async def test_chat_step_streams_rich_chunks_and_resumes_session(tmp_path):
assert response.answer == "hello"
assert chunks[0].tool_call_name == "search"
assert chunks[1].session_id == "session-new"
assert chunks[2].metadata["answer"] == "hello"
assert chunks[2].chunk["result"] == "hello"
assert agent.reply_kwargs["resume"] == "session-old"
assert agent.reply_kwargs["job_tools"] == [
"search",