cloudagent-daemon 0.5.1__tar.gz → 0.5.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/CHANGELOG.md +24 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/PKG-INFO +1 -1
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/pyproject.toml +1 -1
- cloudagent_daemon-0.5.3/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/claude_runner.py +144 -23
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/main_loop.py +24 -3
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/mcp_bridge.py +78 -15
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/outbound_queue.py +3 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/protocol.py +10 -1
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_mcp_bridge_round_trip.py +25 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_spawn_mcp_bridge_e2e.py +26 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner.py +302 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_mcp_bridge_agent_retrieval.py +14 -2
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_mcp_bridge_send_message.py +5 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_outbound_queue.py +11 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/uv.lock +1 -1
- cloudagent_daemon-0.5.1/src/cloudagent_daemon/__init__.py +0 -1
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/.gitignore +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/LICENSE +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/README.md +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/scripts/release.sh +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_download.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_upload.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/auth_preflight.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/cli.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/context_fetch.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/delegation_report.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/frame_dispatch.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/http_outbound.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/injection_state.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/paths.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/qoder_credentials.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/runtime_config.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/runtime_detect.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/service.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/__init__.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/_fake_sse_server.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/conftest.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_e2e_sse.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_full_e2e.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_one_shot_cli.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_sse_handshake.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_sse_watchdog.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_ws_handshake.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/integration/test_ws_reconnect.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/conftest.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_agent_download.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_agent_upload.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_auth_preflight.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_codex_contract.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_routing_contract.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_claude_runner_w3.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_cli_multi_profile.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_cli_smoke.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_config.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_context_fetch.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_delegation_report.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_doctor.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_frame_dispatch.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_http_outbound.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_injection_state.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_jsonl_logging.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_login.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_main_loop_assign.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_main_loop_preflight.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_metrics.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_metrics_server.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_multi_tool_wake.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_packaging_mcp_pin.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_paths.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_protocol.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_qoder_credentials.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_readonly_bash_guard.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_runtime_detect.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_service.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_service_multi_profile.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_spawn_outbox.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_spawn_queue.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_sse_active_watchdog.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_start_guard.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_status_stop.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_workspace.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_workspace_root_task113.py +0 -0
- {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.3}/tests/unit/test_ws_client_insecure.py +0 -0
|
@@ -4,6 +4,30 @@ All notable changes to the local daemon. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); versions are
|
|
5
5
|
[PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
|
|
6
6
|
|
|
7
|
+
## [0.5.3] — 2026-08-22
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- A wake that dies because the model's context window is full now drops the
|
|
11
|
+
oversized session and re-spawns WITHOUT `--resume`, instead of replaying the
|
|
12
|
+
same history after an 8s backoff. Replaying could never fit, so the retry was
|
|
13
|
+
guaranteed to fail; three of them in a row crossed the crash threshold and
|
|
14
|
+
quarantined the agent — silently, since a quarantined agent stops being woken
|
|
15
|
+
(#114). The cleared session id is persisted, so later wakes do not resume the
|
|
16
|
+
oversized thread either.
|
|
17
|
+
- The engine's own error text is now carried on the spawn result. Previously
|
|
18
|
+
only a bare `had_api_error` boolean survived, so the retry layer could not
|
|
19
|
+
tell a transient rate-limit (retry with resume) from a permanent
|
|
20
|
+
context-window overflow (start a new thread) and treated both the same.
|
|
21
|
+
- A context-exhausted wake reports `context_exhausted` rather than the generic
|
|
22
|
+
`upstream_api_error`, so the cause is visible without reading daemon logs.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
- Detection matches an apostrophe-free substring: the engine writes "the
|
|
26
|
+
model's context window", and a smart-quote rewrite upstream would otherwise
|
|
27
|
+
silently disable the whole fix. A test pins that invariant.
|
|
28
|
+
- No 0.5.2 entry exists in this file; 0.5.2 was published manually and its
|
|
29
|
+
changelog entry was never written.
|
|
30
|
+
|
|
7
31
|
## [0.5.1] — 2026-08-20
|
|
8
32
|
|
|
9
33
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
|
|
5
5
|
Project-URL: Homepage, https://cloudagent.chat
|
|
6
6
|
Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudagent-daemon"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.3"
|
|
8
8
|
description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
|
|
9
9
|
authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.3"
|
|
@@ -338,6 +338,13 @@ class _SpawnAttempt:
|
|
|
338
338
|
# M8 — set True when claude emitted an ``isApiErrorMessage:true``
|
|
339
339
|
# event during the wake (upstream API error swallowed by the CLI).
|
|
340
340
|
had_api_error: bool = False
|
|
341
|
+
# #114 — the engine's OWN explanation for that error. codex reports
|
|
342
|
+
# upstream failures as a ``turn.failed``/``error`` stdout event whose
|
|
343
|
+
# message previously reached only the log, so the retry layer saw a
|
|
344
|
+
# bare boolean and could not tell a transient rate-limit apart from a
|
|
345
|
+
# permanent context-window overflow — and retried both the same way.
|
|
346
|
+
# Keeping the text is what lets the wake layer branch on the cause.
|
|
347
|
+
api_error_text: str | None = None
|
|
341
348
|
|
|
342
349
|
|
|
343
350
|
def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
|
|
@@ -391,6 +398,7 @@ class ClaudeRunner:
|
|
|
391
398
|
partial_publisher: PartialPublisher | None = None,
|
|
392
399
|
event_publisher: EventPublisher | None = None,
|
|
393
400
|
agent_to_session: "dict[str, str | None] | None" = None,
|
|
401
|
+
agent_to_delivery: "dict[str, str | None] | None" = None,
|
|
394
402
|
claude_permission_mode: str = "bypassPermissions",
|
|
395
403
|
runtime_binaries: dict[str, str] | None = None,
|
|
396
404
|
http: object | None = None,
|
|
@@ -440,6 +448,10 @@ class ClaudeRunner:
|
|
|
440
448
|
# unix socket ``get_current_session`` op instead of reading a file.
|
|
441
449
|
# None means feature disabled (legacy / test path).
|
|
442
450
|
self._agent_to_session: dict[str, str | None] | None = agent_to_session
|
|
451
|
+
# #112 — opaque backend delivery provenance for the currently running
|
|
452
|
+
# turn. The MCP bridge queries this map through daemon IPC and adds it
|
|
453
|
+
# to outbound calls without exposing a forgeable tool parameter.
|
|
454
|
+
self._agent_to_delivery: dict[str, str | None] | None = agent_to_delivery
|
|
443
455
|
|
|
444
456
|
# ------------------------------------------------------------------
|
|
445
457
|
# Public API
|
|
@@ -462,6 +474,22 @@ class ClaudeRunner:
|
|
|
462
474
|
"no rollout found for thread",
|
|
463
475
|
)
|
|
464
476
|
|
|
477
|
+
#: #114 — 上下文窗口撑满。这是**永久性**前提失败,不是瞬时错误:退避几秒
|
|
478
|
+
#: 再把同一份历史送一次,必然再次溢出。codex 自己给出的处置就是 "Start a
|
|
479
|
+
#: new thread or clear earlier history",与 stale-session 的补救动作完全
|
|
480
|
+
#: 一致(清掉会话、免 resume 重跑),故复用同一条路径而非退避重试。
|
|
481
|
+
#: 匹配按**小写子串**做,同时覆盖 codex 的自然语言提示和 OpenAI API 的
|
|
482
|
+
#: 标准错误码,避免只认一种措辞。
|
|
483
|
+
#: 刻意**不含撇号**:codex 的原文是 "the model's context window",但撇号是
|
|
484
|
+
#: 最容易被改写成弯引号(’)的字符,一旦不匹配整个修复就静默失效——而这个修复
|
|
485
|
+
#: 存在的意义正是消除静默失败。去掉撇号后的子串依然足够specific。
|
|
486
|
+
CONTEXT_EXHAUSTED_MARKERS: tuple[str, ...] = (
|
|
487
|
+
"ran out of room in the model",
|
|
488
|
+
"context_length_exceeded",
|
|
489
|
+
"maximum context length",
|
|
490
|
+
"context window",
|
|
491
|
+
)
|
|
492
|
+
|
|
465
493
|
#: 会产生外部副作用的工具(发消息/建任务类)。瞬时错误重试只在本次 wake
|
|
466
494
|
#: **没**调过这些工具时才做 —— 只读工具(list_messages 等)重跑无害。
|
|
467
495
|
SIDE_EFFECT_TOOLS: frozenset[str] = frozenset({
|
|
@@ -618,6 +646,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
618
646
|
# reading the file. Both paths coexist for backward compat.
|
|
619
647
|
if self._agent_to_session is not None:
|
|
620
648
|
self._agent_to_session[agent_id] = _effective_session_id
|
|
649
|
+
current_delivery_id = payload.get("delivery_id")
|
|
650
|
+
_effective_delivery_id = (
|
|
651
|
+
current_delivery_id
|
|
652
|
+
if isinstance(current_delivery_id, str) and current_delivery_id
|
|
653
|
+
else None
|
|
654
|
+
)
|
|
655
|
+
if self._agent_to_delivery is not None:
|
|
656
|
+
self._agent_to_delivery[agent_id] = _effective_delivery_id
|
|
621
657
|
|
|
622
658
|
# M7 W3 — localagent company-context injection. BEFORE spawning
|
|
623
659
|
# claude, if this deliver belongs to a task whose context has not
|
|
@@ -649,10 +685,22 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
649
685
|
self._qoder_lane_session(ws, stale_conv)
|
|
650
686
|
if stale_is_qoder and stale_conv else None
|
|
651
687
|
)
|
|
688
|
+
# #114 — 上下文撑满与 stale-session 是同一类问题:**当前会话已经
|
|
689
|
+
# 不能再用了**,区别只在于一个是引擎不认识它、另一个是它太大。补救
|
|
690
|
+
# 动作完全相同(清掉会话、免 resume 重跑一次),所以走同一条路径,
|
|
691
|
+
# 而不是掉进下面的瞬时退避重试——那会带着同一份撑满的历史重放,
|
|
692
|
+
# 注定再次失败,白烧一轮唤醒和一次计费,然后累计成 crash 进隔离。
|
|
693
|
+
context_exhausted = self._is_context_exhausted(attempt)
|
|
652
694
|
if (
|
|
653
695
|
self._run_mode_for(payload) != "read_analyze"
|
|
654
696
|
and attempt.exit_code != 0
|
|
655
|
-
and
|
|
697
|
+
and (
|
|
698
|
+
any(
|
|
699
|
+
m in attempt.stderr_tail
|
|
700
|
+
for m in self.STALE_SESSION_STDERR_MARKERS
|
|
701
|
+
)
|
|
702
|
+
or context_exhausted
|
|
703
|
+
)
|
|
656
704
|
and (ws.last_session_id() is not None or qoder_lane_sid is not None)
|
|
657
705
|
):
|
|
658
706
|
stale_sid = qoder_lane_sid or ws.last_session_id()
|
|
@@ -660,11 +708,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
660
708
|
if stale_is_qoder and stale_conv:
|
|
661
709
|
self._qoder_set_lane(ws, stale_conv, None)
|
|
662
710
|
logger.warning(
|
|
663
|
-
"claude_runner:
|
|
664
|
-
"
|
|
665
|
-
"
|
|
711
|
+
"claude_runner: dropping unusable session_id=%s "
|
|
712
|
+
"(cause=%s, detail=%r). Clearing and retrying "
|
|
713
|
+
"without resume.",
|
|
666
714
|
stale_sid,
|
|
667
|
-
|
|
715
|
+
"context_exhausted" if context_exhausted else "stale_session",
|
|
716
|
+
(attempt.api_error_text or attempt.stderr_tail)[-160:],
|
|
668
717
|
)
|
|
669
718
|
try:
|
|
670
719
|
ws.clear_last_session_id()
|
|
@@ -684,6 +733,12 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
684
733
|
attempt.exit_code != 0
|
|
685
734
|
and attempt.had_api_error
|
|
686
735
|
and not attempt.timeout_hit
|
|
736
|
+
# #114 — 永久性失败不走瞬时重试。退避 8 秒不会让上下文变小,
|
|
737
|
+
# 带 resume 重放同一份历史必然再次撑满;上面那条路径已经用
|
|
738
|
+
# "清会话 + 免 resume" 处理过它了。注意这里重新判定的是**重试
|
|
739
|
+
# 之后**的 attempt:若换新会话仍然撑满(例如注入的正文本身过
|
|
740
|
+
# 大),同样不该再退避重放。
|
|
741
|
+
and not self._is_context_exhausted(attempt)
|
|
687
742
|
and not any(
|
|
688
743
|
t.rsplit("__", 1)[-1] in self.SIDE_EFFECT_TOOLS
|
|
689
744
|
for t in attempt.tool_uses
|
|
@@ -709,6 +764,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
709
764
|
# (c) IPC architecture upgrade — clear from memory map.
|
|
710
765
|
if self._agent_to_session is not None:
|
|
711
766
|
self._agent_to_session.pop(agent_id, None)
|
|
767
|
+
if self._agent_to_delivery is not None:
|
|
768
|
+
self._agent_to_delivery.pop(agent_id, None)
|
|
712
769
|
|
|
713
770
|
captured_session = attempt.captured_session_id
|
|
714
771
|
rc = attempt.exit_code
|
|
@@ -830,6 +887,11 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
830
887
|
"content": attempt.final_result_text,
|
|
831
888
|
"conversation_id": platform_conv_id,
|
|
832
889
|
"acknowledge_new_messages": True,
|
|
890
|
+
**(
|
|
891
|
+
{"delivery_id": _effective_delivery_id}
|
|
892
|
+
if _effective_delivery_id
|
|
893
|
+
else {}
|
|
894
|
+
),
|
|
833
895
|
},
|
|
834
896
|
timeout=15.0,
|
|
835
897
|
)
|
|
@@ -876,6 +938,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
876
938
|
payload, attempt.final_result_text
|
|
877
939
|
)
|
|
878
940
|
)
|
|
941
|
+
if _effective_delivery_id:
|
|
942
|
+
fallback_args["delivery_id"] = _effective_delivery_id
|
|
879
943
|
await self.outbound.send_tool_call(
|
|
880
944
|
agent_id=agent_id,
|
|
881
945
|
session_id=fallback_session,
|
|
@@ -982,7 +1046,13 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
982
1046
|
and parent_for_abort not in (None, "")
|
|
983
1047
|
):
|
|
984
1048
|
if attempt.had_api_error:
|
|
985
|
-
|
|
1049
|
+
# #114 — 把永久性的"会话撑满"和笼统的上游错误区分开:否则频道
|
|
1050
|
+
# 里那条 wake_aborted 只说"上游错误",真实原因只能靠翻日志。
|
|
1051
|
+
reason = (
|
|
1052
|
+
"context_exhausted"
|
|
1053
|
+
if self._is_context_exhausted(attempt)
|
|
1054
|
+
else "upstream_api_error"
|
|
1055
|
+
)
|
|
986
1056
|
elif timeout_hit:
|
|
987
1057
|
reason = "wake_timeout"
|
|
988
1058
|
else:
|
|
@@ -1006,7 +1076,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
1006
1076
|
agent_id=agent_id,
|
|
1007
1077
|
session_id=fallback_session,
|
|
1008
1078
|
tool_name="send_message",
|
|
1009
|
-
args=
|
|
1079
|
+
args={
|
|
1080
|
+
**self._fallback_reply_args(payload, abort_body),
|
|
1081
|
+
**(
|
|
1082
|
+
{"delivery_id": _effective_delivery_id}
|
|
1083
|
+
if _effective_delivery_id
|
|
1084
|
+
else {}
|
|
1085
|
+
),
|
|
1086
|
+
},
|
|
1010
1087
|
timeout=15.0,
|
|
1011
1088
|
)
|
|
1012
1089
|
try:
|
|
@@ -1026,15 +1103,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
1026
1103
|
reason,
|
|
1027
1104
|
)
|
|
1028
1105
|
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
exit_code=rc,
|
|
1033
|
-
conversation_id=(
|
|
1106
|
+
final_status_kwargs: dict[str, Any] = {
|
|
1107
|
+
"exit_code": rc,
|
|
1108
|
+
"conversation_id": (
|
|
1034
1109
|
str(payload["conversation_id"])
|
|
1035
1110
|
if payload.get("conversation_id")
|
|
1036
1111
|
else None
|
|
1037
1112
|
),
|
|
1113
|
+
}
|
|
1114
|
+
if _effective_delivery_id:
|
|
1115
|
+
final_status_kwargs["delivery_id"] = _effective_delivery_id
|
|
1116
|
+
await self.outbound.send_agent_status(
|
|
1117
|
+
agent_id,
|
|
1118
|
+
final_status,
|
|
1119
|
+
**final_status_kwargs,
|
|
1038
1120
|
)
|
|
1039
1121
|
|
|
1040
1122
|
wake_secs = time.monotonic() - wake_start
|
|
@@ -1403,14 +1485,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
1403
1485
|
await self.outbound.send_ack(
|
|
1404
1486
|
seq=None, message_id=message_id, status="started"
|
|
1405
1487
|
)
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
"running",
|
|
1409
|
-
conversation_id=(
|
|
1488
|
+
running_status_kwargs: dict[str, Any] = {
|
|
1489
|
+
"conversation_id": (
|
|
1410
1490
|
str(payload["conversation_id"])
|
|
1411
1491
|
if payload.get("conversation_id")
|
|
1412
1492
|
else None
|
|
1413
|
-
)
|
|
1493
|
+
)
|
|
1494
|
+
}
|
|
1495
|
+
delivery_id = payload.get("delivery_id")
|
|
1496
|
+
if isinstance(delivery_id, str) and delivery_id:
|
|
1497
|
+
running_status_kwargs["delivery_id"] = delivery_id
|
|
1498
|
+
await self.outbound.send_agent_status(
|
|
1499
|
+
agent_id,
|
|
1500
|
+
"running",
|
|
1501
|
+
**running_status_kwargs,
|
|
1414
1502
|
)
|
|
1415
1503
|
|
|
1416
1504
|
# 1k74 — token-level streaming. Forward the deliver's session_id
|
|
@@ -1425,6 +1513,7 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
1425
1513
|
final_text,
|
|
1426
1514
|
result_usage,
|
|
1427
1515
|
had_api_error,
|
|
1516
|
+
api_error_text,
|
|
1428
1517
|
) = await self._consume_stdout(
|
|
1429
1518
|
proc,
|
|
1430
1519
|
parse_mode=(
|
|
@@ -1455,12 +1544,34 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
|
|
|
1455
1544
|
final_result_text=final_text,
|
|
1456
1545
|
result_usage=result_usage,
|
|
1457
1546
|
had_api_error=had_api_error,
|
|
1547
|
+
api_error_text=api_error_text,
|
|
1458
1548
|
)
|
|
1459
1549
|
|
|
1460
1550
|
# ------------------------------------------------------------------
|
|
1461
1551
|
# Internal helpers
|
|
1462
1552
|
# ------------------------------------------------------------------
|
|
1463
1553
|
|
|
1554
|
+
def _is_context_exhausted(self, attempt: _SpawnAttempt) -> bool:
|
|
1555
|
+
"""#114 — True iff *attempt* failed because the model's context
|
|
1556
|
+
window is full.
|
|
1557
|
+
|
|
1558
|
+
A **permanent** premise failure, not a transient upstream error:
|
|
1559
|
+
the same history replayed after any backoff overflows the window
|
|
1560
|
+
again, so retrying with ``--resume`` burns a wake and a billing
|
|
1561
|
+
cycle to reach the identical failure. The remedy is the one the
|
|
1562
|
+
engine itself prints — start a new thread — which is exactly what
|
|
1563
|
+
the stale-session path already does, so both share it.
|
|
1564
|
+
|
|
1565
|
+
Reads only :attr:`_SpawnAttempt.api_error_text` (the engine's own
|
|
1566
|
+
message). An attempt that carried no explanation returns False:
|
|
1567
|
+
unknown cause keeps the previous, more conservative handling
|
|
1568
|
+
rather than silently discarding a session that may be fine.
|
|
1569
|
+
"""
|
|
1570
|
+
text = (attempt.api_error_text or "").lower()
|
|
1571
|
+
if not text:
|
|
1572
|
+
return False
|
|
1573
|
+
return any(m in text for m in self.CONTEXT_EXHAUSTED_MARKERS)
|
|
1574
|
+
|
|
1464
1575
|
#: 1i Bug E — the daemon-injected ``cloudagent`` MCP toolset MUST run
|
|
1465
1576
|
#: unattended in ``--print`` headless mode (otherwise claude prompts
|
|
1466
1577
|
#: the user for permission, the prompt is never answered, and the
|
|
@@ -2246,7 +2357,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2246
2357
|
*,
|
|
2247
2358
|
partial_session_id: str | None = None,
|
|
2248
2359
|
partial_agent_id: str | None = None,
|
|
2249
|
-
) -> tuple[str | None, list[str], str | None, dict | None, bool]:
|
|
2360
|
+
) -> tuple[str | None, list[str], str | None, dict | None, bool, str | None]:
|
|
2250
2361
|
"""Consume ``codex exec --json`` NDJSON.
|
|
2251
2362
|
|
|
2252
2363
|
Codex emits ``thread.started`` with ``thread_id`` and one or more
|
|
@@ -2264,13 +2375,16 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2264
2375
|
# codex 把上游 API 错误(模型 at capacity / 限流)作为 turn.failed/error
|
|
2265
2376
|
# 事件吐在 stdout —— 记为 had_api_error,wake 层据此做瞬时错误重试。
|
|
2266
2377
|
had_api_error = False
|
|
2378
|
+
# #114 — 同时留下引擎给的原因文本。只有一个布尔时,"限流"和"上下文
|
|
2379
|
+
# 撑满"在 wake 层无法区分,于是永久性失败被当成瞬时错误反复重放。
|
|
2380
|
+
api_error_text: str | None = None
|
|
2267
2381
|
publish_enabled = bool(
|
|
2268
2382
|
self._partial_publisher
|
|
2269
2383
|
and partial_session_id
|
|
2270
2384
|
and partial_agent_id
|
|
2271
2385
|
)
|
|
2272
2386
|
if proc.stdout is None:
|
|
2273
|
-
return None, tool_uses, None, None, False
|
|
2387
|
+
return None, tool_uses, None, None, False, None
|
|
2274
2388
|
|
|
2275
2389
|
async def _emit(event_type: str, payload: "dict[str, Any]") -> None:
|
|
2276
2390
|
if (
|
|
@@ -2428,9 +2542,12 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2428
2542
|
msg = raw if isinstance(raw, str) else None
|
|
2429
2543
|
logger.warning("CodexRunner: stdout error event=%s msg=%r", evt_type, msg)
|
|
2430
2544
|
had_api_error = True
|
|
2545
|
+
# 第一条为准:后续事件通常是同一个失败的级联噪声,首条才是根因。
|
|
2546
|
+
if api_error_text is None and isinstance(msg, str) and msg:
|
|
2547
|
+
api_error_text = msg
|
|
2431
2548
|
|
|
2432
2549
|
final_text = "\n\n".join(final_chunks) if final_chunks else None
|
|
2433
|
-
return captured, tool_uses, final_text, result_usage, had_api_error
|
|
2550
|
+
return captured, tool_uses, final_text, result_usage, had_api_error, api_error_text
|
|
2434
2551
|
|
|
2435
2552
|
async def _consume_stdout(
|
|
2436
2553
|
self,
|
|
@@ -2439,7 +2556,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2439
2556
|
parse_mode: str = "stream_json",
|
|
2440
2557
|
partial_session_id: str | None = None,
|
|
2441
2558
|
partial_agent_id: str | None = None,
|
|
2442
|
-
) -> tuple[str | None, list[str], str | None, dict | None, bool]:
|
|
2559
|
+
) -> tuple[str | None, list[str], str | None, dict | None, bool, str | None]:
|
|
2443
2560
|
"""Stream NDJSON; return ``(session_id, tool_uses, result_text, result_usage, had_api_error)``.
|
|
2444
2561
|
|
|
2445
2562
|
Accepts BOTH session-init event shapes for forward/backward
|
|
@@ -2491,6 +2608,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2491
2608
|
# M8 — set True when claude emits a synthetic isApiErrorMessage
|
|
2492
2609
|
# assistant event (upstream API error swallowed by the CLI).
|
|
2493
2610
|
had_api_error: bool = False
|
|
2611
|
+
# #114 — 与 codex 侧同构:保留引擎自己的错误文本供 wake 层判定原因。
|
|
2612
|
+
api_error_text: str | None = None
|
|
2494
2613
|
# 1k74 — running buffer for token-level streaming. claude emits
|
|
2495
2614
|
# one ``stream_event`` per content_block_delta (~80 chars at a
|
|
2496
2615
|
# time); we concatenate them and forward the running total to
|
|
@@ -2530,7 +2649,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2530
2649
|
)
|
|
2531
2650
|
|
|
2532
2651
|
if proc.stdout is None:
|
|
2533
|
-
return None, tool_uses, final_text, result_usage, had_api_error
|
|
2652
|
+
return None, tool_uses, final_text, result_usage, had_api_error, api_error_text
|
|
2534
2653
|
|
|
2535
2654
|
async for line_b in proc.stdout:
|
|
2536
2655
|
line = line_b.decode("utf-8", errors="replace").strip()
|
|
@@ -2645,6 +2764,8 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2645
2764
|
if isinstance(t, str):
|
|
2646
2765
|
err_text = t[:200]
|
|
2647
2766
|
break
|
|
2767
|
+
if api_error_text is None and err_text:
|
|
2768
|
+
api_error_text = err_text
|
|
2648
2769
|
logger.warning(
|
|
2649
2770
|
"ClaudeRunner: stdout isApiErrorMessage event — "
|
|
2650
2771
|
"wake will be marked crashed (text=%r)",
|
|
@@ -2784,7 +2905,7 @@ TASKS (频道任务板) — 频道里的分工协作走任务认领原语,纪
|
|
|
2784
2905
|
"claude_runner: cache metrics publish failed", exc_info=True
|
|
2785
2906
|
)
|
|
2786
2907
|
|
|
2787
|
-
return captured, tool_uses, final_text, result_usage, had_api_error
|
|
2908
|
+
return captured, tool_uses, final_text, result_usage, had_api_error, api_error_text
|
|
2788
2909
|
|
|
2789
2910
|
async def _wait_with_timeout(
|
|
2790
2911
|
self, proc: asyncio.subprocess.Process
|
|
@@ -637,6 +637,7 @@ async def _run_sse_post(
|
|
|
637
637
|
# IPC op instead of reading a file; file fallback kept for backward
|
|
638
638
|
# compat (old daemons without this map still work via the file).
|
|
639
639
|
_agent_to_session: dict[str, str | None] = {}
|
|
640
|
+
_agent_to_delivery: dict[str, str | None] = {}
|
|
640
641
|
|
|
641
642
|
# M7 W3 — localagent context injection + delegation reporting.
|
|
642
643
|
# ``injection_state`` is the file-backed (agent,task) dedup store
|
|
@@ -657,6 +658,7 @@ async def _run_sse_post(
|
|
|
657
658
|
partial_publisher=_publish_partial,
|
|
658
659
|
event_publisher=_publish_event,
|
|
659
660
|
agent_to_session=_agent_to_session,
|
|
661
|
+
agent_to_delivery=_agent_to_delivery,
|
|
660
662
|
claude_permission_mode=cfg.claude_permission_mode,
|
|
661
663
|
runtime_binaries=runtime_binaries,
|
|
662
664
|
http=http,
|
|
@@ -752,11 +754,17 @@ async def _run_sse_post(
|
|
|
752
754
|
a = req.get("args") or {}
|
|
753
755
|
agent_id_q = a.get("agent_id") or ""
|
|
754
756
|
sid = _agent_to_session.get(agent_id_q)
|
|
757
|
+
delivery_id = _agent_to_delivery.get(agent_id_q)
|
|
755
758
|
logger.info(
|
|
756
|
-
"main_loop.ipc_handler: get_current_session agent=%s sid=%s",
|
|
757
|
-
agent_id_q, sid,
|
|
759
|
+
"main_loop.ipc_handler: get_current_session agent=%s sid=%s delivery=%s",
|
|
760
|
+
agent_id_q, sid, delivery_id,
|
|
758
761
|
)
|
|
759
|
-
return {
|
|
762
|
+
return {
|
|
763
|
+
"result": {
|
|
764
|
+
"session_id": sid,
|
|
765
|
+
"delivery_id": delivery_id,
|
|
766
|
+
}
|
|
767
|
+
}
|
|
760
768
|
|
|
761
769
|
if op != "tool_call":
|
|
762
770
|
logger.warning(
|
|
@@ -981,6 +989,8 @@ async def _run_legacy_ws(
|
|
|
981
989
|
delegation_outbox = DelegationOutbox(
|
|
982
990
|
profile_delegation_outbox_path(profile)
|
|
983
991
|
)
|
|
992
|
+
agent_to_session_ws: dict[str, str | None] = {}
|
|
993
|
+
agent_to_delivery_ws: dict[str, str | None] = {}
|
|
984
994
|
|
|
985
995
|
runner = ClaudeRunner(
|
|
986
996
|
outbound=outbound,
|
|
@@ -991,6 +1001,8 @@ async def _run_legacy_ws(
|
|
|
991
1001
|
http=ws_http,
|
|
992
1002
|
injection_state=injection_state,
|
|
993
1003
|
delegation_outbox=delegation_outbox,
|
|
1004
|
+
agent_to_session=agent_to_session_ws,
|
|
1005
|
+
agent_to_delivery=agent_to_delivery_ws,
|
|
994
1006
|
)
|
|
995
1007
|
|
|
996
1008
|
# #25 — pre-flight auth self-check (parity with the SSE path). Detached
|
|
@@ -1071,6 +1083,15 @@ async def _run_legacy_ws(
|
|
|
1071
1083
|
sock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1072
1084
|
|
|
1073
1085
|
async def ipc_handler(req: dict) -> dict:
|
|
1086
|
+
if req.get("op") == "get_current_session":
|
|
1087
|
+
a = req.get("args") or {}
|
|
1088
|
+
agent_id_q = a.get("agent_id") or ""
|
|
1089
|
+
return {
|
|
1090
|
+
"result": {
|
|
1091
|
+
"session_id": agent_to_session_ws.get(agent_id_q),
|
|
1092
|
+
"delivery_id": agent_to_delivery_ws.get(agent_id_q),
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1074
1095
|
if req.get("op") != "tool_call":
|
|
1075
1096
|
return {"error": {"code": "UNKNOWN_OP", "message": str(req.get("op"))}}
|
|
1076
1097
|
a = req.get("args") or {}
|
|
@@ -121,6 +121,35 @@ def _read_identity(agent_id: str) -> dict:
|
|
|
121
121
|
return {}
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
async def _query_daemon_context(
|
|
125
|
+
agent_id: str, socket_path: Path
|
|
126
|
+
) -> tuple[str | None, str | None]:
|
|
127
|
+
"""Return the daemon-owned ``(session_id, delivery_id)`` for this turn.
|
|
128
|
+
|
|
129
|
+
Old daemons return no delivery id (or reject the IPC op), which cleanly
|
|
130
|
+
degrades to ``(None, None)``. The delivery id is intentionally absent from
|
|
131
|
+
every MCP tool schema: the bridge attaches it after the model has chosen
|
|
132
|
+
its ordinary tool arguments.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
async with UnixSocketClient(socket_path) as ipc:
|
|
136
|
+
result = await ipc.call(
|
|
137
|
+
"get_current_session",
|
|
138
|
+
{"agent_id": agent_id},
|
|
139
|
+
timeout=5.0,
|
|
140
|
+
)
|
|
141
|
+
sid = result.get("session_id")
|
|
142
|
+
delivery_id = result.get("delivery_id")
|
|
143
|
+
return (
|
|
144
|
+
sid if isinstance(sid, str) and sid else None,
|
|
145
|
+
delivery_id
|
|
146
|
+
if isinstance(delivery_id, str) and delivery_id
|
|
147
|
+
else None,
|
|
148
|
+
)
|
|
149
|
+
except Exception: # noqa: BLE001
|
|
150
|
+
return None, None
|
|
151
|
+
|
|
152
|
+
|
|
124
153
|
async def _query_daemon_session(
|
|
125
154
|
agent_id: str, socket_path: Path
|
|
126
155
|
) -> str | None:
|
|
@@ -134,19 +163,8 @@ async def _query_daemon_session(
|
|
|
134
163
|
Returns ``None`` on any failure (socket missing, timeout, old daemon
|
|
135
164
|
without the op) so the caller can fall back to the file path.
|
|
136
165
|
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
result = await ipc.call(
|
|
140
|
-
"get_current_session",
|
|
141
|
-
{"agent_id": agent_id},
|
|
142
|
-
timeout=5.0,
|
|
143
|
-
)
|
|
144
|
-
sid = result.get("session_id")
|
|
145
|
-
return sid if isinstance(sid, str) and sid else None
|
|
146
|
-
except Exception: # noqa: BLE001
|
|
147
|
-
# Socket missing, old daemon (UNKNOWN_OP), or timeout — fall
|
|
148
|
-
# through to file-based path. Not an error: backward compat.
|
|
149
|
-
return None
|
|
166
|
+
sid, _delivery_id = await _query_daemon_context(agent_id, socket_path)
|
|
167
|
+
return sid
|
|
150
168
|
|
|
151
169
|
|
|
152
170
|
def _read_current_session(
|
|
@@ -190,6 +208,7 @@ async def _ipc_tool_call(
|
|
|
190
208
|
tool_name: str,
|
|
191
209
|
tool_args: dict,
|
|
192
210
|
session_id: str | None,
|
|
211
|
+
delivery_id: str | None = None,
|
|
193
212
|
) -> dict:
|
|
194
213
|
"""Single round-trip: open socket → send tool_call → return result."""
|
|
195
214
|
# 1k #55 — log the tool dispatch BEFORE we touch the socket so we can
|
|
@@ -200,6 +219,9 @@ async def _ipc_tool_call(
|
|
|
200
219
|
tool_name, agent_id, session_id,
|
|
201
220
|
)
|
|
202
221
|
try:
|
|
222
|
+
effective_tool_args = dict(tool_args)
|
|
223
|
+
if delivery_id:
|
|
224
|
+
effective_tool_args["delivery_id"] = delivery_id
|
|
203
225
|
async with UnixSocketClient(socket_path) as ipc:
|
|
204
226
|
logger.info(
|
|
205
227
|
"mcp_bridge: ipc connected socket=%s tool=%s agent=%s session=%s",
|
|
@@ -211,7 +233,7 @@ async def _ipc_tool_call(
|
|
|
211
233
|
"agent_id": agent_id,
|
|
212
234
|
"session_id": session_id,
|
|
213
235
|
"tool_name": tool_name,
|
|
214
|
-
"tool_args":
|
|
236
|
+
"tool_args": effective_tool_args,
|
|
215
237
|
},
|
|
216
238
|
)
|
|
217
239
|
logger.info(
|
|
@@ -227,11 +249,30 @@ async def _ipc_tool_call(
|
|
|
227
249
|
raise
|
|
228
250
|
|
|
229
251
|
|
|
252
|
+
# Resolve the module-level helper at CALL time, not import time.
|
|
253
|
+
#
|
|
254
|
+
# ``build_server``'s provenance-stamping closure shadows the name
|
|
255
|
+
# ``_ipc_tool_call`` locally, so it needs another way to reach the module
|
|
256
|
+
# helper. A plain alias (``_raw_ipc_tool_call = _ipc_tool_call``) binds the
|
|
257
|
+
# original function object once, at import — which silently defeats every
|
|
258
|
+
# test that does ``monkeypatch.setattr(bridge, "_ipc_tool_call", fake)``:
|
|
259
|
+
# the patch rebinds the module attribute, the frozen alias keeps pointing at
|
|
260
|
+
# the real implementation, and the "unit" test goes off and opens a real unix
|
|
261
|
+
# socket. That is exactly how 10 tests started failing with FileNotFoundError.
|
|
262
|
+
#
|
|
263
|
+
# Looking the attribute up through the module at call time keeps the closure
|
|
264
|
+
# working AND lets the patch take effect. Production behaviour is unchanged:
|
|
265
|
+
# unpatched, this returns the same function the alias used to hold.
|
|
266
|
+
def _module_ipc_tool_call(): # type: ignore[no-untyped-def]
|
|
267
|
+
return sys.modules[__name__]._ipc_tool_call # type: ignore[attr-defined,no-any-return]
|
|
268
|
+
|
|
269
|
+
|
|
230
270
|
def build_server(
|
|
231
271
|
agent_id: str,
|
|
232
272
|
socket_path: Path = DEFAULT_SOCKET_PATH,
|
|
233
273
|
identity: dict | None = None,
|
|
234
274
|
session_id: str | None = None,
|
|
275
|
+
delivery_id: str | None = None,
|
|
235
276
|
tool_profile: str = "full",
|
|
236
277
|
) -> FastMCP:
|
|
237
278
|
"""Construct a FastMCP server filtered by identity and tool profile.
|
|
@@ -269,6 +310,25 @@ def build_server(
|
|
|
269
310
|
|
|
270
311
|
server = FastMCP(name=f"cloudagent-bridge-{agent_id}")
|
|
271
312
|
|
|
313
|
+
# Keep the opaque provenance out of every public MCP schema. The nested
|
|
314
|
+
# wrapper shadows the module helper for all tool closures below and stamps
|
|
315
|
+
# the daemon-provided value only after the model's arguments are built.
|
|
316
|
+
async def _ipc_tool_call( # type: ignore[no-redef]
|
|
317
|
+
socket_path: Path,
|
|
318
|
+
agent_id: str,
|
|
319
|
+
tool_name: str,
|
|
320
|
+
tool_args: dict,
|
|
321
|
+
session_id: str | None,
|
|
322
|
+
) -> dict:
|
|
323
|
+
return await _module_ipc_tool_call()(
|
|
324
|
+
socket_path,
|
|
325
|
+
agent_id,
|
|
326
|
+
tool_name,
|
|
327
|
+
tool_args,
|
|
328
|
+
session_id,
|
|
329
|
+
delivery_id,
|
|
330
|
+
)
|
|
331
|
+
|
|
272
332
|
# ---- Common tools (all agents) ----------------------------------------
|
|
273
333
|
|
|
274
334
|
@server.tool(
|
|
@@ -924,7 +984,9 @@ async def run_bridge(
|
|
|
924
984
|
# freshly-written current-session.txt). Fall back to file on any
|
|
925
985
|
# failure so old daemons without the ``get_current_session`` op
|
|
926
986
|
# keep working.
|
|
927
|
-
current_session_id = await
|
|
987
|
+
current_session_id, current_delivery_id = await _query_daemon_context(
|
|
988
|
+
agent_id, socket_path
|
|
989
|
+
)
|
|
928
990
|
if current_session_id is not None:
|
|
929
991
|
logger.info(
|
|
930
992
|
"mcp_bridge: agent=%s current_session=%s (via socket IPC)",
|
|
@@ -951,6 +1013,7 @@ async def run_bridge(
|
|
|
951
1013
|
socket_path=socket_path,
|
|
952
1014
|
identity=identity,
|
|
953
1015
|
session_id=current_session_id,
|
|
1016
|
+
delivery_id=current_delivery_id,
|
|
954
1017
|
tool_profile=tool_profile,
|
|
955
1018
|
)
|
|
956
1019
|
logger.info(
|
|
@@ -156,6 +156,7 @@ class OutboundQueue:
|
|
|
156
156
|
status: str,
|
|
157
157
|
exit_code: int | None = None,
|
|
158
158
|
conversation_id: str | None = None,
|
|
159
|
+
delivery_id: str | None = None,
|
|
159
160
|
) -> None:
|
|
160
161
|
"""Send an ``agent_status`` frame."""
|
|
161
162
|
payload: dict = {"agent_id": agent_id, "status": status}
|
|
@@ -163,6 +164,8 @@ class OutboundQueue:
|
|
|
163
164
|
payload["exit_code"] = exit_code
|
|
164
165
|
if conversation_id:
|
|
165
166
|
payload["conversation_id"] = conversation_id
|
|
167
|
+
if delivery_id:
|
|
168
|
+
payload["delivery_id"] = delivery_id
|
|
166
169
|
|
|
167
170
|
await self.send_raw({
|
|
168
171
|
"v": 1,
|