Improve replayed run status handling
This commit is contained in:
+56
-18
@@ -830,6 +830,13 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
agent = state.agent_for(request.account_id, requested_session_id)
|
agent = state.agent_for(request.account_id, requested_session_id)
|
||||||
config = state.config_for(request.account_id)
|
config = state.config_for(request.account_id)
|
||||||
session_directory = state.account_paths(request.account_id)['sessions']
|
session_directory = state.account_paths(request.account_id)['sessions']
|
||||||
|
|
||||||
|
def emit_agent_event(event: dict[str, object]) -> None:
|
||||||
|
stage = _runtime_event_stage(event)
|
||||||
|
if stage:
|
||||||
|
state.run_manager.update(run_record.run_id, stage=stage)
|
||||||
|
_emit_runtime_event(event_sink, event)
|
||||||
|
|
||||||
if request.resume_session_id is None:
|
if request.resume_session_id is None:
|
||||||
_save_in_progress_session(
|
_save_in_progress_session(
|
||||||
directory=session_directory,
|
directory=session_directory,
|
||||||
@@ -898,14 +905,14 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
request.prompt.strip(),
|
request.prompt.strip(),
|
||||||
stored,
|
stored,
|
||||||
runtime_context=request.runtime_context,
|
runtime_context=request.runtime_context,
|
||||||
event_sink=event_sink,
|
event_sink=emit_agent_event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = agent.run(
|
result = agent.run(
|
||||||
request.prompt.strip(),
|
request.prompt.strip(),
|
||||||
session_id=requested_session_id,
|
session_id=requested_session_id,
|
||||||
runtime_context=request.runtime_context,
|
runtime_context=request.runtime_context,
|
||||||
event_sink=event_sink,
|
event_sink=emit_agent_event,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_mark_session_interrupted(
|
_mark_session_interrupted(
|
||||||
@@ -1164,23 +1171,11 @@ def _sanitize_stored_session_for_resume(
|
|||||||
stored: StoredAgentSession,
|
stored: StoredAgentSession,
|
||||||
) -> StoredAgentSession:
|
) -> StoredAgentSession:
|
||||||
trimmed, changed = _trim_incomplete_message_tail(stored.messages)
|
trimmed, changed = _trim_incomplete_message_tail(stored.messages)
|
||||||
|
messages = _without_run_status_messages(trimmed)
|
||||||
|
if len(messages) != len(trimmed):
|
||||||
|
changed = True
|
||||||
if not changed:
|
if not changed:
|
||||||
return stored
|
return stored
|
||||||
messages = list(trimmed)
|
|
||||||
if not _last_message_is_run_status(messages, 'interrupted'):
|
|
||||||
messages.append(
|
|
||||||
{
|
|
||||||
'role': 'assistant',
|
|
||||||
'content': _interrupted_session_message('interrupted'),
|
|
||||||
'state': 'final',
|
|
||||||
'stop_reason': 'interrupted',
|
|
||||||
'metadata': {
|
|
||||||
'kind': 'run_status',
|
|
||||||
'status': 'interrupted',
|
|
||||||
'created_at_ms': int(time.time() * 1000),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
budget_state = (
|
budget_state = (
|
||||||
dict(stored.budget_state)
|
dict(stored.budget_state)
|
||||||
if isinstance(stored.budget_state, dict)
|
if isinstance(stored.budget_state, dict)
|
||||||
@@ -1189,7 +1184,7 @@ def _sanitize_stored_session_for_resume(
|
|||||||
budget_state['status'] = 'interrupted'
|
budget_state['status'] = 'interrupted'
|
||||||
return replace(
|
return replace(
|
||||||
stored,
|
stored,
|
||||||
messages=tuple(messages),
|
messages=messages,
|
||||||
budget_state=budget_state,
|
budget_state=budget_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1232,6 +1227,21 @@ def _assistant_has_trailing_tool_call(message: dict[str, Any]) -> bool:
|
|||||||
return isinstance(tool_calls, list) and bool(tool_calls)
|
return isinstance(tool_calls, list) and bool(tool_calls)
|
||||||
|
|
||||||
|
|
||||||
|
def _without_run_status_messages(
|
||||||
|
messages: tuple[dict[str, Any], ...],
|
||||||
|
) -> tuple[dict[str, Any], ...]:
|
||||||
|
return tuple(
|
||||||
|
message
|
||||||
|
for message in messages
|
||||||
|
if not _is_run_status_message(message)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_run_status_message(message: dict[str, Any]) -> bool:
|
||||||
|
metadata = message.get('metadata')
|
||||||
|
return isinstance(metadata, dict) and metadata.get('kind') == 'run_status'
|
||||||
|
|
||||||
|
|
||||||
def _last_message_is_run_status(messages: list[dict[str, Any]], status: str) -> bool:
|
def _last_message_is_run_status(messages: list[dict[str, Any]], status: str) -> bool:
|
||||||
if not messages:
|
if not messages:
|
||||||
return False
|
return False
|
||||||
@@ -1251,6 +1261,34 @@ def _interrupted_session_message(status: str) -> str:
|
|||||||
return '上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。'
|
return '上一次任务已中断,后台没有正在执行的进程。你可以继续回复,或重新发起任务。'
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_event_stage(event: dict[str, object]) -> str:
|
||||||
|
event_type = event.get('type')
|
||||||
|
if event_type == 'tool_start':
|
||||||
|
tool_name = str(event.get('tool_name') or '').strip()
|
||||||
|
return f'调用工具 {tool_name}' if tool_name else '正在调用工具'
|
||||||
|
if event_type == 'tool_result':
|
||||||
|
tool_name = str(event.get('tool_name') or '').strip()
|
||||||
|
return f'工具完成 {tool_name}' if tool_name else '工具调用完成'
|
||||||
|
if event_type == 'final_text_start':
|
||||||
|
return '正在整理回复'
|
||||||
|
if event_type == 'final_text_end':
|
||||||
|
return '回复整理完成'
|
||||||
|
if event_type == 'user_review_required':
|
||||||
|
return '等待用户 review'
|
||||||
|
if event_type == 'continuation_request':
|
||||||
|
return '继续补全回复'
|
||||||
|
if event_type in {
|
||||||
|
'prompt_length_check',
|
||||||
|
'prompt_length_recovery',
|
||||||
|
'auto_compact_summary',
|
||||||
|
'auto_compact_circuit_breaker',
|
||||||
|
}:
|
||||||
|
return '整理上下文'
|
||||||
|
if event_type == 'task_budget_exceeded':
|
||||||
|
return '达到任务预算限制'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _emit_runtime_event(
|
def _emit_runtime_event(
|
||||||
event_sink: Any | None,
|
event_sink: Any | None,
|
||||||
event: dict[str, object],
|
event: dict[str, object],
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type ClawStoredMessage = {
|
|||||||
stop_reason?: string;
|
stop_reason?: string;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
elapsed_ms?: unknown;
|
elapsed_ms?: unknown;
|
||||||
|
kind?: unknown;
|
||||||
placeholder?: unknown;
|
placeholder?: unknown;
|
||||||
phase?: unknown;
|
phase?: unknown;
|
||||||
status?: unknown;
|
status?: unknown;
|
||||||
@@ -523,8 +524,11 @@ function getReplayMessages(
|
|||||||
messages: readonly ClawStoredMessage[],
|
messages: readonly ClawStoredMessage[],
|
||||||
runStatus?: ClawRunStatus | null,
|
runStatus?: ClawRunStatus | null,
|
||||||
) {
|
) {
|
||||||
if (isActiveRunStatus(runStatus)) return [...messages];
|
const visibleMessages = isActiveRunStatus(runStatus)
|
||||||
return trimIncompleteReplayTail(messages);
|
? messages.filter((message) => !isRunStatusMessage(message))
|
||||||
|
: [...messages];
|
||||||
|
if (isActiveRunStatus(runStatus)) return visibleMessages;
|
||||||
|
return trimIncompleteReplayTail(visibleMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowInterruptedNotice(
|
function shouldShowInterruptedNotice(
|
||||||
@@ -532,6 +536,7 @@ function shouldShowInterruptedNotice(
|
|||||||
runStatus?: ClawRunStatus | null,
|
runStatus?: ClawRunStatus | null,
|
||||||
) {
|
) {
|
||||||
if (isActiveRunStatus(runStatus)) return false;
|
if (isActiveRunStatus(runStatus)) return false;
|
||||||
|
if (hasRunStatusMessage(messages, runStatus?.status)) return false;
|
||||||
return (
|
return (
|
||||||
runStatus?.status === "interrupted" ||
|
runStatus?.status === "interrupted" ||
|
||||||
runStatus?.status === "cancelled" ||
|
runStatus?.status === "cancelled" ||
|
||||||
@@ -574,6 +579,21 @@ function hasTrailingAssistantToolCall(message?: ClawStoredMessage) {
|
|||||||
return message?.role === "assistant" && Boolean(message.tool_calls?.length);
|
return message?.role === "assistant" && Boolean(message.tool_calls?.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRunStatusMessage(
|
||||||
|
messages: readonly ClawStoredMessage[],
|
||||||
|
status?: string,
|
||||||
|
) {
|
||||||
|
return messages.some(
|
||||||
|
(message) =>
|
||||||
|
isRunStatusMessage(message) &&
|
||||||
|
(!status || message.metadata?.status === status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunStatusMessage(message?: ClawStoredMessage) {
|
||||||
|
return message?.metadata?.kind === "run_status";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRunTimestampMs(value: unknown) {
|
function normalizeRunTimestampMs(value: unknown) {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||||
return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value);
|
return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value);
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from backend.api.server import AgentState, create_app
|
from backend.api.server import (
|
||||||
|
AgentState,
|
||||||
|
create_app,
|
||||||
|
_runtime_event_stage,
|
||||||
|
_sanitize_stored_session_for_resume,
|
||||||
|
)
|
||||||
from src.agent_types import AgentRunResult
|
from src.agent_types import AgentRunResult
|
||||||
from src.session_store import StoredAgentSession, load_agent_session, save_agent_session
|
from src.session_store import StoredAgentSession, load_agent_session, save_agent_session
|
||||||
|
|
||||||
@@ -360,6 +365,52 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json()['status'], 'interrupted')
|
self.assertEqual(response.json()['status'], 'interrupted')
|
||||||
|
|
||||||
|
def test_resume_sanitizer_removes_ui_run_status_messages(self) -> None:
|
||||||
|
stored = StoredAgentSession(
|
||||||
|
session_id='thread-1',
|
||||||
|
model_config={'model': 'test-model'},
|
||||||
|
runtime_config={},
|
||||||
|
system_prompt_parts=('system prompt',),
|
||||||
|
user_context={},
|
||||||
|
system_context={},
|
||||||
|
messages=(
|
||||||
|
{'role': 'user', 'content': 'hello'},
|
||||||
|
{
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': '上一次任务已中断,后台没有正在执行的进程。',
|
||||||
|
'state': 'final',
|
||||||
|
'stop_reason': 'interrupted',
|
||||||
|
'metadata': {'kind': 'run_status', 'status': 'interrupted'},
|
||||||
|
},
|
||||||
|
{'role': 'user', 'content': 'continue'},
|
||||||
|
),
|
||||||
|
turns=1,
|
||||||
|
tool_calls=0,
|
||||||
|
usage={},
|
||||||
|
total_cost_usd=0.0,
|
||||||
|
file_history=(),
|
||||||
|
budget_state={'status': 'interrupted'},
|
||||||
|
plugin_state={},
|
||||||
|
)
|
||||||
|
|
||||||
|
sanitized = _sanitize_stored_session_for_resume(stored)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[message['content'] for message in sanitized.messages],
|
||||||
|
['hello', 'continue'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_event_stage_names_long_running_work(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
_runtime_event_stage({'type': 'tool_start', 'tool_name': 'python_exec'}),
|
||||||
|
'调用工具 python_exec',
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_runtime_event_stage({'type': 'final_text_start'}),
|
||||||
|
'正在整理回复',
|
||||||
|
)
|
||||||
|
self.assertEqual(_runtime_event_stage({'type': 'server_heartbeat'}), '')
|
||||||
|
|
||||||
def test_chat_uses_account_scoped_model_config(self) -> None:
|
def test_chat_uses_account_scoped_model_config(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
client, _ = _build_client(Path(d))
|
client, _ = _build_client(Path(d))
|
||||||
|
|||||||
Reference in New Issue
Block a user