mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-14 09:01:22 +00:00
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:
parent
70877ce85b
commit
2a307f80a8
93 changed files with 3026 additions and 1041 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 的操作串行。
|
||||
|
|
|
|||
|
|
@ -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__} "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="关注政策变化。",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
61
reme/schema/agent_reply.py
Normal file
61
reme/schema/agent_reply.py
Normal 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))
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
437
tests/unit/test_agent_reply.py
Normal file
437
tests/unit/test_agent_reply.py
Normal 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)
|
||||
163
tests/unit/test_agent_reply_consumers.py
Normal file
163
tests/unit/test_agent_reply_consumers.py
Normal 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)
|
||||
270
tests/unit/test_agent_stream.py
Normal file
270
tests/unit/test_agent_stream.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}]))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"] == {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue