Persist active run event history
This commit is contained in:
+107
-16
@@ -14,7 +14,7 @@ import shutil
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import venv
|
import venv
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock, RLock
|
from threading import Lock, RLock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -214,6 +214,7 @@ class RunRecord:
|
|||||||
process_registry: RunProcessRegistry
|
process_registry: RunProcessRegistry
|
||||||
current_stage: str = ''
|
current_stage: str = ''
|
||||||
error: str = ''
|
error: str = ''
|
||||||
|
events: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class RunManager:
|
class RunManager:
|
||||||
@@ -252,6 +253,20 @@ class RunManager:
|
|||||||
record.error = error
|
record.error = error
|
||||||
record.updated_at = time.time()
|
record.updated_at = time.time()
|
||||||
|
|
||||||
|
def record_event(self, run_id: str, event: dict[str, object]) -> None:
|
||||||
|
normalized = _normalize_run_event(event)
|
||||||
|
if normalized is None:
|
||||||
|
return
|
||||||
|
normalized['recorded_at'] = time.time()
|
||||||
|
with self._lock:
|
||||||
|
record = self._runs.get(run_id)
|
||||||
|
if record is None:
|
||||||
|
return
|
||||||
|
record.events.append(normalized)
|
||||||
|
if len(record.events) > 160:
|
||||||
|
del record.events[: len(record.events) - 160]
|
||||||
|
record.updated_at = time.time()
|
||||||
|
|
||||||
def finish(self, run_id: str, status: str) -> None:
|
def finish(self, run_id: str, status: str) -> None:
|
||||||
self.update(run_id, status=status)
|
self.update(run_id, status=status)
|
||||||
|
|
||||||
@@ -286,6 +301,7 @@ class RunManager:
|
|||||||
'started_at': record.started_at,
|
'started_at': record.started_at,
|
||||||
'updated_at': record.updated_at,
|
'updated_at': record.updated_at,
|
||||||
'error': record.error,
|
'error': record.error,
|
||||||
|
'events': [dict(event) for event in record.events],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _select_session_record(
|
def _select_session_record(
|
||||||
@@ -835,6 +851,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
stage = _runtime_event_stage(event)
|
stage = _runtime_event_stage(event)
|
||||||
if stage:
|
if stage:
|
||||||
state.run_manager.update(run_record.run_id, stage=stage)
|
state.run_manager.update(run_record.run_id, stage=stage)
|
||||||
|
state.run_manager.record_event(run_record.run_id, event)
|
||||||
_emit_runtime_event(event_sink, event)
|
_emit_runtime_event(event_sink, event)
|
||||||
|
|
||||||
if request.resume_session_id is None:
|
if request.resume_session_id is None:
|
||||||
@@ -849,14 +866,16 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
requested_session_id,
|
requested_session_id,
|
||||||
)
|
)
|
||||||
if run_lock.locked():
|
if run_lock.locked():
|
||||||
|
queued_event = {
|
||||||
|
'type': 'run_queued',
|
||||||
|
'run_id': run_record.run_id,
|
||||||
|
'session_id': requested_session_id or '',
|
||||||
|
'account_id': request.account_id or '',
|
||||||
|
}
|
||||||
|
state.run_manager.record_event(run_record.run_id, queued_event)
|
||||||
_emit_runtime_event(
|
_emit_runtime_event(
|
||||||
event_sink,
|
event_sink,
|
||||||
{
|
queued_event,
|
||||||
'type': 'run_queued',
|
|
||||||
'run_id': run_record.run_id,
|
|
||||||
'session_id': requested_session_id or '',
|
|
||||||
'account_id': request.account_id or '',
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
with run_lock:
|
with run_lock:
|
||||||
if run_record.cancel_event.is_set():
|
if run_record.cancel_event.is_set():
|
||||||
@@ -872,15 +891,14 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
'stop_reason': 'cancelled',
|
'stop_reason': 'cancelled',
|
||||||
}
|
}
|
||||||
state.run_manager.update(run_record.run_id, status='running', stage='后端开始执行')
|
state.run_manager.update(run_record.run_id, status='running', stage='后端开始执行')
|
||||||
_emit_runtime_event(
|
started_event = {
|
||||||
event_sink,
|
'type': 'run_started',
|
||||||
{
|
'run_id': run_record.run_id,
|
||||||
'type': 'run_started',
|
'session_id': requested_session_id or '',
|
||||||
'run_id': run_record.run_id,
|
'account_id': request.account_id or '',
|
||||||
'session_id': requested_session_id or '',
|
}
|
||||||
'account_id': request.account_id or '',
|
state.run_manager.record_event(run_record.run_id, started_event)
|
||||||
},
|
_emit_runtime_event(event_sink, started_event)
|
||||||
)
|
|
||||||
agent.tool_context = replace(
|
agent.tool_context = replace(
|
||||||
agent.tool_context,
|
agent.tool_context,
|
||||||
cancel_event=run_record.cancel_event,
|
cancel_event=run_record.cancel_event,
|
||||||
@@ -1289,6 +1307,79 @@ def _runtime_event_stage(event: dict[str, object]) -> str:
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
_RUN_EVENT_TYPES = {
|
||||||
|
'run_queued',
|
||||||
|
'run_started',
|
||||||
|
'tool_start',
|
||||||
|
'tool_result',
|
||||||
|
'final_text_start',
|
||||||
|
'final_text_end',
|
||||||
|
'user_review_required',
|
||||||
|
'continuation_request',
|
||||||
|
'prompt_length_check',
|
||||||
|
'prompt_length_recovery',
|
||||||
|
'auto_compact_summary',
|
||||||
|
'auto_compact_circuit_breaker',
|
||||||
|
'task_budget_exceeded',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_run_event(event: dict[str, object]) -> dict[str, Any] | None:
|
||||||
|
event_type = event.get('type')
|
||||||
|
if not isinstance(event_type, str) or event_type not in _RUN_EVENT_TYPES:
|
||||||
|
return None
|
||||||
|
allowed = {
|
||||||
|
'type',
|
||||||
|
'run_id',
|
||||||
|
'session_id',
|
||||||
|
'account_id',
|
||||||
|
'tool_name',
|
||||||
|
'tool_call_id',
|
||||||
|
'arguments',
|
||||||
|
'assistant_content',
|
||||||
|
'message_id',
|
||||||
|
'ok',
|
||||||
|
'metadata',
|
||||||
|
'reason',
|
||||||
|
'continuation_index',
|
||||||
|
'turn_index',
|
||||||
|
'strategy',
|
||||||
|
'projected_input_tokens',
|
||||||
|
'soft_input_limit_tokens',
|
||||||
|
'hard_input_limit_tokens',
|
||||||
|
'exceeds_hard_limit',
|
||||||
|
'exceeds_soft_limit',
|
||||||
|
}
|
||||||
|
normalized = {
|
||||||
|
key: _json_safe_limited(value)
|
||||||
|
for key, value in event.items()
|
||||||
|
if key in allowed and value is not None
|
||||||
|
}
|
||||||
|
normalized['type'] = event_type
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe_limited(value: Any, *, depth: int = 0) -> Any:
|
||||||
|
if depth > 4:
|
||||||
|
return str(value)[:500]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:2000]
|
||||||
|
if isinstance(value, (int, float, bool)) or value is None:
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(key)[:200]: _json_safe_limited(item, depth=depth + 1)
|
||||||
|
for key, item in list(value.items())[:80]
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_json_safe_limited(item, depth=depth + 1) for item in value[:80]]
|
||||||
|
try:
|
||||||
|
json.dumps(value)
|
||||||
|
return value
|
||||||
|
except TypeError:
|
||||||
|
return str(value)[:1000]
|
||||||
|
|
||||||
|
|
||||||
def _emit_runtime_event(
|
def _emit_runtime_event(
|
||||||
event_sink: Any | None,
|
event_sink: Any | None,
|
||||||
event: dict[str, object],
|
event: dict[str, object],
|
||||||
|
|||||||
@@ -64,12 +64,25 @@ export type ClawRunStatus = {
|
|||||||
| "interrupted";
|
| "interrupted";
|
||||||
current_stage?: string;
|
current_stage?: string;
|
||||||
started_at?: number;
|
started_at?: number;
|
||||||
|
events?: ClawRunEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClawActiveRunStatus = ClawRunStatus & {
|
type ClawActiveRunStatus = ClawRunStatus & {
|
||||||
status: "queued" | "running";
|
status: "queued" | "running";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClawRunEvent = {
|
||||||
|
type?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
arguments?: unknown;
|
||||||
|
assistant_content?: string;
|
||||||
|
ok?: boolean;
|
||||||
|
metadata?: {
|
||||||
|
output_preview?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ClawStoredToolCall = {
|
type ClawStoredToolCall = {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -471,11 +484,7 @@ export function toReplayRepository(
|
|||||||
}
|
}
|
||||||
if (isActiveRunStatus(runStatus)) {
|
if (isActiveRunStatus(runStatus)) {
|
||||||
const id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`;
|
const id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`;
|
||||||
const text =
|
const parts = buildActiveRunParts(runStatus) as UIMessage["parts"];
|
||||||
runStatus.status === "queued"
|
|
||||||
? "当前会话已有任务在执行,本轮正在排队。"
|
|
||||||
: runStatus.current_stage || "后端正在执行这个会话。";
|
|
||||||
const parts = [{ type: "reasoning", text }] as UIMessage["parts"];
|
|
||||||
const uiMessage: UIMessage = {
|
const uiMessage: UIMessage = {
|
||||||
id,
|
id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -520,6 +529,92 @@ function isActiveRunStatus(
|
|||||||
return runStatus?.status === "queued" || runStatus?.status === "running";
|
return runStatus?.status === "queued" || runStatus?.status === "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildActiveRunParts(runStatus: ClawActiveRunStatus) {
|
||||||
|
const eventLines = summarizeRunEvents(runStatus);
|
||||||
|
const fallback =
|
||||||
|
runStatus.status === "queued"
|
||||||
|
? "当前会话已有任务在执行,本轮正在排队。"
|
||||||
|
: runStatus.current_stage || "后端正在执行这个会话。";
|
||||||
|
const reasoningText = eventLines.length ? eventLines.join("\n") : fallback;
|
||||||
|
return [
|
||||||
|
{ type: "reasoning", text: reasoningText },
|
||||||
|
...buildRunToolParts(runStatus.events ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRunEvents(runStatus: ClawActiveRunStatus) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const event of runStatus.events ?? []) {
|
||||||
|
if (event.type === "run_queued") {
|
||||||
|
lines.push("当前会话已有任务在执行,本轮正在排队。");
|
||||||
|
}
|
||||||
|
if (event.type === "run_started") {
|
||||||
|
lines.push("后端已开始执行本轮任务。");
|
||||||
|
}
|
||||||
|
if (event.type === "tool_start") {
|
||||||
|
lines.push(
|
||||||
|
event.tool_name ? `调用工具 ${event.tool_name}` : "正在调用工具",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.type === "tool_result") {
|
||||||
|
lines.push(
|
||||||
|
event.tool_name ? `工具完成 ${event.tool_name}` : "工具调用完成",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.type === "final_text_start") {
|
||||||
|
lines.push("正在整理回复");
|
||||||
|
}
|
||||||
|
if (event.type === "user_review_required") {
|
||||||
|
lines.push("等待用户 review");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (runStatus.current_stage && lines.at(-1) !== runStatus.current_stage) {
|
||||||
|
lines.push(runStatus.current_stage);
|
||||||
|
}
|
||||||
|
return lines.slice(-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRunToolParts(events: readonly ClawRunEvent[]) {
|
||||||
|
const resultById = new Map<string, ClawRunEvent>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === "tool_result" && event.tool_call_id) {
|
||||||
|
resultById.set(event.tool_call_id, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return events.flatMap((event) => {
|
||||||
|
if (
|
||||||
|
event.type !== "tool_start" ||
|
||||||
|
!event.tool_call_id ||
|
||||||
|
!event.tool_name ||
|
||||||
|
seen.has(event.tool_call_id)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
seen.add(event.tool_call_id);
|
||||||
|
const result = resultById.get(event.tool_call_id);
|
||||||
|
const outputPreview = result?.metadata?.output_preview;
|
||||||
|
const output =
|
||||||
|
typeof outputPreview === "string"
|
||||||
|
? outputPreview
|
||||||
|
: result
|
||||||
|
? result.ok === false
|
||||||
|
? "工具执行失败,等待最终结果汇总。"
|
||||||
|
: "工具调用完成,等待最终结果汇总。"
|
||||||
|
: undefined;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "dynamic-tool",
|
||||||
|
toolName: event.tool_name,
|
||||||
|
toolCallId: event.tool_call_id,
|
||||||
|
state: output === undefined ? "input-available" : "output-available",
|
||||||
|
input: attachStageNote(event.arguments ?? {}, event.assistant_content),
|
||||||
|
...(output === undefined ? {} : { output }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getReplayMessages(
|
function getReplayMessages(
|
||||||
messages: readonly ClawStoredMessage[],
|
messages: readonly ClawStoredMessage[],
|
||||||
runStatus?: ClawRunStatus | null,
|
runStatus?: ClawRunStatus | null,
|
||||||
|
|||||||
+26
-3
@@ -1270,9 +1270,15 @@ class LocalCodingAgent:
|
|||||||
self.last_run_result = result
|
self.last_run_result = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
final_output = (
|
final_output = self._build_max_turns_output(
|
||||||
last_content
|
max_turns=self.runtime_config.max_turns,
|
||||||
or 'Stopped: max turns reached before the model produced a final answer.'
|
last_content=last_content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
)
|
||||||
|
session.append_assistant(
|
||||||
|
final_output,
|
||||||
|
message_id=f'assistant_{len(session.messages)}',
|
||||||
|
stop_reason='max_turns',
|
||||||
)
|
)
|
||||||
_append_final_text_stream_events(stream_events, final_output)
|
_append_final_text_stream_events(stream_events, final_output)
|
||||||
result = AgentRunResult(
|
result = AgentRunResult(
|
||||||
@@ -1299,6 +1305,23 @@ class LocalCodingAgent:
|
|||||||
self.last_run_result = result
|
self.last_run_result = result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_max_turns_output(
|
||||||
|
*,
|
||||||
|
max_turns: int,
|
||||||
|
last_content: str,
|
||||||
|
tool_calls: int,
|
||||||
|
) -> str:
|
||||||
|
lines = [
|
||||||
|
f'已达到本轮最大步骤上限({max_turns} 轮),为了避免继续空转,当前任务已暂停。',
|
||||||
|
]
|
||||||
|
if tool_calls:
|
||||||
|
lines.append('最后一轮已经执行完工具,但还没有来得及整理成最终回复。')
|
||||||
|
if last_content.strip():
|
||||||
|
lines.append(f'最后一次阶段说明:{last_content.strip()}')
|
||||||
|
lines.append('你可以继续补充指令,我会基于当前会话结果接着处理。')
|
||||||
|
return '\n\n'.join(lines)
|
||||||
|
|
||||||
def _query_model(
|
def _query_model(
|
||||||
self,
|
self,
|
||||||
session: AgentSessionState,
|
session: AgentSessionState,
|
||||||
|
|||||||
@@ -285,6 +285,52 @@ class AgentRuntimeTests(unittest.TestCase):
|
|||||||
self.assertGreaterEqual(len(result.transcript), 5)
|
self.assertGreaterEqual(len(result.transcript), 5)
|
||||||
self.assertGreaterEqual(len(result.file_history), 0)
|
self.assertGreaterEqual(len(result.file_history), 0)
|
||||||
|
|
||||||
|
def test_agent_persists_max_turns_explanation_after_tool_result(self) -> None:
|
||||||
|
responses = [
|
||||||
|
{
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': {
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': '我先读取文件。',
|
||||||
|
'tool_calls': [
|
||||||
|
{
|
||||||
|
'id': 'call_1',
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'read_file',
|
||||||
|
'arguments': '{"path": "hello.txt"}',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'finish_reason': 'tool_calls',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
workspace = Path(tmp_dir)
|
||||||
|
(workspace / 'hello.txt').write_text('hello world\n', encoding='utf-8')
|
||||||
|
with patch(
|
||||||
|
'src.openai_compat.request.urlopen',
|
||||||
|
side_effect=make_urlopen_side_effect(responses),
|
||||||
|
):
|
||||||
|
agent = LocalCodingAgent(
|
||||||
|
model_config=ModelConfig(
|
||||||
|
model='Qwen/Qwen3-Coder-30B-A3B-Instruct',
|
||||||
|
base_url='http://127.0.0.1:8000/v1',
|
||||||
|
),
|
||||||
|
runtime_config=AgentRuntimeConfig(cwd=workspace, max_turns=1),
|
||||||
|
)
|
||||||
|
result = agent.run('Inspect hello.txt')
|
||||||
|
|
||||||
|
self.assertEqual(result.stop_reason, 'max_turns')
|
||||||
|
self.assertIn('最大步骤上限', result.final_output)
|
||||||
|
self.assertEqual(result.transcript[-1]['role'], 'assistant')
|
||||||
|
self.assertEqual(result.transcript[-1]['stop_reason'], 'max_turns')
|
||||||
|
self.assertIn('最大步骤上限', result.transcript[-1]['content'])
|
||||||
|
|
||||||
def test_agent_stops_after_data_agent_goal_requires_review(self) -> None:
|
def test_agent_stops_after_data_agent_goal_requires_review(self) -> None:
|
||||||
responses = [
|
responses = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -262,6 +262,43 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload['status'], 'running')
|
self.assertEqual(payload['status'], 'running')
|
||||||
self.assertEqual(payload['current_stage'], 'first stage')
|
self.assertEqual(payload['current_stage'], 'first stage')
|
||||||
|
|
||||||
|
def test_latest_run_includes_runtime_event_history(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
client, state = _build_client(Path(d))
|
||||||
|
record = state.run_manager.start('alice', 'thread-1')
|
||||||
|
state.run_manager.update(record.run_id, status='running')
|
||||||
|
state.run_manager.record_event(
|
||||||
|
record.run_id,
|
||||||
|
{
|
||||||
|
'type': 'tool_start',
|
||||||
|
'tool_name': 'python_exec',
|
||||||
|
'tool_call_id': 'call-1',
|
||||||
|
'arguments': {'code': 'print(1)'},
|
||||||
|
'assistant_content': '我用 Python 分析一下。',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.run_manager.record_event(
|
||||||
|
record.run_id,
|
||||||
|
{
|
||||||
|
'type': 'tool_result',
|
||||||
|
'tool_name': 'python_exec',
|
||||||
|
'tool_call_id': 'call-1',
|
||||||
|
'ok': True,
|
||||||
|
'metadata': {'output_preview': 'exit_code=0'},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
latest = client.get(
|
||||||
|
'/api/runs/latest',
|
||||||
|
params={'account_id': 'alice', 'session_id': 'thread-1'},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(latest.status_code, 200)
|
||||||
|
events = latest.json()['events']
|
||||||
|
self.assertEqual([event['type'] for event in events], ['tool_start', 'tool_result'])
|
||||||
|
self.assertEqual(events[0]['tool_name'], 'python_exec')
|
||||||
|
self.assertEqual(events[1]['metadata']['output_preview'], 'exit_code=0')
|
||||||
|
|
||||||
def test_cancel_run_marks_streaming_session_tail_cancelled(self) -> None:
|
def test_cancel_run_marks_streaming_session_tail_cancelled(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
root = Path(d)
|
root = Path(d)
|
||||||
|
|||||||
Reference in New Issue
Block a user