cloudagent-daemon 0.5.1__tar.gz → 0.5.2__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.
Files changed (102) hide show
  1. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/PKG-INFO +1 -1
  2. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/pyproject.toml +1 -1
  3. cloudagent_daemon-0.5.2/src/cloudagent_daemon/__init__.py +1 -0
  4. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/claude_runner.py +51 -11
  5. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/main_loop.py +24 -3
  6. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/mcp_bridge.py +78 -15
  7. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/outbound_queue.py +3 -0
  8. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/protocol.py +10 -1
  9. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_mcp_bridge_round_trip.py +25 -0
  10. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_spawn_mcp_bridge_e2e.py +26 -0
  11. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner.py +22 -0
  12. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_mcp_bridge_agent_retrieval.py +14 -2
  13. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_mcp_bridge_send_message.py +5 -0
  14. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_outbound_queue.py +11 -0
  15. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/uv.lock +1 -1
  16. cloudagent_daemon-0.5.1/src/cloudagent_daemon/__init__.py +0 -1
  17. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/.gitignore +0 -0
  18. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/CHANGELOG.md +0 -0
  19. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/LICENSE +0 -0
  20. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/README.md +0 -0
  21. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/scripts/release.sh +0 -0
  22. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/__main__.py +0 -0
  23. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/agent_download.py +0 -0
  24. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/agent_upload.py +0 -0
  25. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  26. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/auth_preflight.py +0 -0
  27. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/cli.py +0 -0
  28. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/config.py +0 -0
  29. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/context_fetch.py +0 -0
  30. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/delegation_report.py +0 -0
  31. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/doctor.py +0 -0
  32. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  33. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/http_outbound.py +0 -0
  34. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/injection_state.py +0 -0
  35. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  36. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/login.py +0 -0
  37. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/metrics.py +0 -0
  38. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/metrics_server.py +0 -0
  39. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/paths.py +0 -0
  40. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/qoder_credentials.py +0 -0
  41. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  42. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/runtime_config.py +0 -0
  43. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/runtime_detect.py +0 -0
  44. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/service.py +0 -0
  45. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  46. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/spawn_queue.py +0 -0
  47. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/sse_client.py +0 -0
  48. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/transport.py +0 -0
  49. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/unix_socket.py +0 -0
  50. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/workspace.py +0 -0
  51. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/src/cloudagent_daemon/ws_client.py +0 -0
  52. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/__init__.py +0 -0
  53. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/_fake_sse_server.py +0 -0
  54. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/conftest.py +0 -0
  55. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  56. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_e2e_sse.py +0 -0
  57. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_full_e2e.py +0 -0
  58. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_one_shot_cli.py +0 -0
  59. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_sse_handshake.py +0 -0
  60. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_sse_watchdog.py +0 -0
  61. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_ws_handshake.py +0 -0
  62. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/integration/test_ws_reconnect.py +0 -0
  63. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/conftest.py +0 -0
  64. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_agent_download.py +0 -0
  65. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_agent_upload.py +0 -0
  66. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_auth_preflight.py +0 -0
  67. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  68. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  69. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_claude_runner_w3.py +0 -0
  70. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_cli_multi_profile.py +0 -0
  71. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_cli_smoke.py +0 -0
  72. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_config.py +0 -0
  73. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_context_fetch.py +0 -0
  74. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_delegation_report.py +0 -0
  75. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_doctor.py +0 -0
  76. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_frame_dispatch.py +0 -0
  77. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_http_outbound.py +0 -0
  78. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_injection_state.py +0 -0
  79. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_jsonl_logging.py +0 -0
  80. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_login.py +0 -0
  81. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_main_loop_assign.py +0 -0
  82. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_main_loop_preflight.py +0 -0
  83. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_metrics.py +0 -0
  84. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_metrics_server.py +0 -0
  85. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_multi_tool_wake.py +0 -0
  86. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  87. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_packaging_mcp_pin.py +0 -0
  88. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_paths.py +0 -0
  89. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_protocol.py +0 -0
  90. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_qoder_credentials.py +0 -0
  91. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_readonly_bash_guard.py +0 -0
  92. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_runtime_detect.py +0 -0
  93. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_service.py +0 -0
  94. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_service_multi_profile.py +0 -0
  95. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_spawn_outbox.py +0 -0
  96. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_spawn_queue.py +0 -0
  97. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_sse_active_watchdog.py +0 -0
  98. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_start_guard.py +0 -0
  99. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_status_stop.py +0 -0
  100. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_workspace.py +0 -0
  101. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_workspace_root_task113.py +0 -0
  102. {cloudagent_daemon-0.5.1 → cloudagent_daemon-0.5.2}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.5.1
3
+ Version: 0.5.2
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.1"
7
+ version = "0.5.2"
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.2"
@@ -391,6 +391,7 @@ class ClaudeRunner:
391
391
  partial_publisher: PartialPublisher | None = None,
392
392
  event_publisher: EventPublisher | None = None,
393
393
  agent_to_session: "dict[str, str | None] | None" = None,
394
+ agent_to_delivery: "dict[str, str | None] | None" = None,
394
395
  claude_permission_mode: str = "bypassPermissions",
395
396
  runtime_binaries: dict[str, str] | None = None,
396
397
  http: object | None = None,
@@ -440,6 +441,10 @@ class ClaudeRunner:
440
441
  # unix socket ``get_current_session`` op instead of reading a file.
441
442
  # None means feature disabled (legacy / test path).
442
443
  self._agent_to_session: dict[str, str | None] | None = agent_to_session
444
+ # #112 — opaque backend delivery provenance for the currently running
445
+ # turn. The MCP bridge queries this map through daemon IPC and adds it
446
+ # to outbound calls without exposing a forgeable tool parameter.
447
+ self._agent_to_delivery: dict[str, str | None] | None = agent_to_delivery
443
448
 
444
449
  # ------------------------------------------------------------------
445
450
  # Public API
@@ -618,6 +623,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
618
623
  # reading the file. Both paths coexist for backward compat.
619
624
  if self._agent_to_session is not None:
620
625
  self._agent_to_session[agent_id] = _effective_session_id
626
+ current_delivery_id = payload.get("delivery_id")
627
+ _effective_delivery_id = (
628
+ current_delivery_id
629
+ if isinstance(current_delivery_id, str) and current_delivery_id
630
+ else None
631
+ )
632
+ if self._agent_to_delivery is not None:
633
+ self._agent_to_delivery[agent_id] = _effective_delivery_id
621
634
 
622
635
  # M7 W3 — localagent company-context injection. BEFORE spawning
623
636
  # claude, if this deliver belongs to a task whose context has not
@@ -709,6 +722,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
709
722
  # (c) IPC architecture upgrade — clear from memory map.
710
723
  if self._agent_to_session is not None:
711
724
  self._agent_to_session.pop(agent_id, None)
725
+ if self._agent_to_delivery is not None:
726
+ self._agent_to_delivery.pop(agent_id, None)
712
727
 
713
728
  captured_session = attempt.captured_session_id
714
729
  rc = attempt.exit_code
@@ -830,6 +845,11 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
830
845
  "content": attempt.final_result_text,
831
846
  "conversation_id": platform_conv_id,
832
847
  "acknowledge_new_messages": True,
848
+ **(
849
+ {"delivery_id": _effective_delivery_id}
850
+ if _effective_delivery_id
851
+ else {}
852
+ ),
833
853
  },
834
854
  timeout=15.0,
835
855
  )
@@ -876,6 +896,8 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
876
896
  payload, attempt.final_result_text
877
897
  )
878
898
  )
899
+ if _effective_delivery_id:
900
+ fallback_args["delivery_id"] = _effective_delivery_id
879
901
  await self.outbound.send_tool_call(
880
902
  agent_id=agent_id,
881
903
  session_id=fallback_session,
@@ -1006,7 +1028,14 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1006
1028
  agent_id=agent_id,
1007
1029
  session_id=fallback_session,
1008
1030
  tool_name="send_message",
1009
- args=self._fallback_reply_args(payload, abort_body),
1031
+ args={
1032
+ **self._fallback_reply_args(payload, abort_body),
1033
+ **(
1034
+ {"delivery_id": _effective_delivery_id}
1035
+ if _effective_delivery_id
1036
+ else {}
1037
+ ),
1038
+ },
1010
1039
  timeout=15.0,
1011
1040
  )
1012
1041
  try:
@@ -1026,15 +1055,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1026
1055
  reason,
1027
1056
  )
1028
1057
 
1029
- await self.outbound.send_agent_status(
1030
- agent_id,
1031
- final_status,
1032
- exit_code=rc,
1033
- conversation_id=(
1058
+ final_status_kwargs: dict[str, Any] = {
1059
+ "exit_code": rc,
1060
+ "conversation_id": (
1034
1061
  str(payload["conversation_id"])
1035
1062
  if payload.get("conversation_id")
1036
1063
  else None
1037
1064
  ),
1065
+ }
1066
+ if _effective_delivery_id:
1067
+ final_status_kwargs["delivery_id"] = _effective_delivery_id
1068
+ await self.outbound.send_agent_status(
1069
+ agent_id,
1070
+ final_status,
1071
+ **final_status_kwargs,
1038
1072
  )
1039
1073
 
1040
1074
  wake_secs = time.monotonic() - wake_start
@@ -1403,14 +1437,20 @@ The daemon discards stdout — only the mcp tool call reaches the channel.
1403
1437
  await self.outbound.send_ack(
1404
1438
  seq=None, message_id=message_id, status="started"
1405
1439
  )
1406
- await self.outbound.send_agent_status(
1407
- agent_id,
1408
- "running",
1409
- conversation_id=(
1440
+ running_status_kwargs: dict[str, Any] = {
1441
+ "conversation_id": (
1410
1442
  str(payload["conversation_id"])
1411
1443
  if payload.get("conversation_id")
1412
1444
  else None
1413
- ),
1445
+ )
1446
+ }
1447
+ delivery_id = payload.get("delivery_id")
1448
+ if isinstance(delivery_id, str) and delivery_id:
1449
+ running_status_kwargs["delivery_id"] = delivery_id
1450
+ await self.outbound.send_agent_status(
1451
+ agent_id,
1452
+ "running",
1453
+ **running_status_kwargs,
1414
1454
  )
1415
1455
 
1416
1456
  # 1k74 — token-level streaming. Forward the deliver's session_id
@@ -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 {"result": {"session_id": sid}}
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
- try:
138
- async with UnixSocketClient(socket_path) as ipc:
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": 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 _query_daemon_session(agent_id, socket_path)
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,
@@ -63,6 +63,10 @@ class DeliverPayload(BaseModel):
63
63
  model: str | None = None
64
64
  reasoning_effort: str | None = None
65
65
  runtime_env: dict[str, str] | None = None
66
+ run_mode: str | None = None
67
+ origin: dict[str, Any] | None = None
68
+ conversation_id: str | None = None
69
+ delivery_id: str | None = None
66
70
 
67
71
 
68
72
  class DeliveryAckPayload(BaseModel):
@@ -95,9 +99,14 @@ class AgentSessionStartPayload(BaseModel):
95
99
 
96
100
  class AgentStatusPayload(BaseModel):
97
101
  agent_id: str
98
- status: Literal["running", "idle", "crashed", "quarantined", "backpressure_dropped"]
102
+ status: Literal[
103
+ "running", "idle", "crashed", "timeout", "quarantined",
104
+ "backpressure_dropped",
105
+ ]
99
106
  exit_code: int | None = None
100
107
  error: str | None = None
108
+ conversation_id: str | None = None
109
+ delivery_id: str | None = None
101
110
 
102
111
 
103
112
  class PingPayload(BaseModel):
@@ -405,6 +405,31 @@ async def test_platform_send_tunnels_via_ipc_with_session_id(
405
405
  }
406
406
 
407
407
 
408
+ async def test_bridge_stamps_delivery_id_outside_tool_schema(
409
+ ipc_server: tuple[Path, list[dict]],
410
+ ) -> None:
411
+ """#112: provenance is daemon-owned context, not a model-selectable
412
+ ``platform_send`` argument, but it must cross IPC on the actual call."""
413
+ sock, received = ipc_server
414
+ server = build_server(
415
+ agent_id="a_platform_provenance",
416
+ socket_path=sock,
417
+ identity={"is_chief": False},
418
+ session_id="sess_plat_provenance",
419
+ delivery_id="del_plat_provenance",
420
+ )
421
+
422
+ tool = next(t for t in await server.list_tools() if t.name == "platform_send")
423
+ assert "delivery_id" not in tool.inputSchema.get("properties", {})
424
+ await server.call_tool(
425
+ "platform_send",
426
+ {"conversation_id": "conv_x1", "content": "带可信上下文"},
427
+ )
428
+
429
+ req = received[0]
430
+ assert req["args"]["tool_args"]["delivery_id"] == "del_plat_provenance"
431
+
432
+
408
433
  # ---------------------------------------------------------------------------
409
434
  # 1k #55 — multi-tool-wake regression
410
435
  # ---------------------------------------------------------------------------
@@ -27,6 +27,7 @@ import pytest
27
27
  import pytest_asyncio
28
28
 
29
29
  from cloudagent_daemon.mcp_bridge import (
30
+ _query_daemon_context,
30
31
  _query_daemon_session,
31
32
  _read_current_session,
32
33
  build_server,
@@ -181,6 +182,31 @@ async def test_query_daemon_session_returns_registered_session(
181
182
  assert sid == "sess_from_memory_abc"
182
183
 
183
184
 
185
+ async def test_query_daemon_context_returns_delivery_provenance(
186
+ short_sock_dir: Path,
187
+ ) -> None:
188
+ sock = short_sock_dir / "ctx.sock"
189
+
190
+ async def handler(req: dict) -> dict:
191
+ assert req.get("op") == "get_current_session"
192
+ return {
193
+ "result": {
194
+ "session_id": "sess_ctx",
195
+ "delivery_id": "del_ctx",
196
+ }
197
+ }
198
+
199
+ server = UnixSocketServer(sock, handler)
200
+ await server.start()
201
+ try:
202
+ assert await _query_daemon_context("agent_ctx", sock) == (
203
+ "sess_ctx",
204
+ "del_ctx",
205
+ )
206
+ finally:
207
+ await server.stop()
208
+
209
+
184
210
  async def test_query_daemon_session_returns_none_when_not_registered(
185
211
  daemon_session_server: tuple[Path, dict, list],
186
212
  ) -> None:
@@ -130,6 +130,7 @@ class FakeOutbound:
130
130
  ) -> None:
131
131
  self.calls: list[tuple] = []
132
132
  self.status_conversation_ids: list[str | None] = []
133
+ self.status_delivery_ids: list[str | None] = []
133
134
  # 1k #63 — record send_tool_call invocations so tests can assert
134
135
  # the daemon-side fallback fires (or doesn't) and inspect its
135
136
  # args. The result is hard-coded ok=True by default; tests that
@@ -156,9 +157,11 @@ class FakeOutbound:
156
157
  status: str,
157
158
  exit_code: int | None = None,
158
159
  conversation_id: str | None = None,
160
+ delivery_id: str | None = None,
159
161
  ) -> None:
160
162
  self.calls.append(("agent_status", agent_id, status, exit_code))
161
163
  self.status_conversation_ids.append(conversation_id)
164
+ self.status_delivery_ids.append(delivery_id)
162
165
 
163
166
  async def send_agent_session_start(
164
167
  self,
@@ -626,6 +629,25 @@ async def test_agent_status_carries_deliver_conversation(fake_claude, tmp_path):
626
629
  assert outbound.status_conversation_ids == ["conv_status", "conv_status"]
627
630
 
628
631
 
632
+ async def test_agent_status_carries_delivery_provenance(fake_claude, tmp_path):
633
+ mcp_cfg = tmp_path / "mcp.json"
634
+ mcp_cfg.write_text("{}")
635
+ ws = FakeWorkspace(FakeAgentWorkspace(mcp_path=mcp_cfg, last_session=None))
636
+ outbound = FakeOutbound()
637
+ runner = _build_runner(
638
+ fake_claude(stdout_lines=[], exit_code=0),
639
+ ws,
640
+ outbound,
641
+ )
642
+
643
+ await runner.run_one(
644
+ "agent-A",
645
+ {**_make_payload(), "delivery_id": "del_status"},
646
+ )
647
+
648
+ assert outbound.status_delivery_ids == ["del_status", "del_status"]
649
+
650
+
629
651
  async def test_emits_status_crashed_on_nonzero_exit(fake_claude, tmp_path):
630
652
  """Non-zero exit → final agent_status is crashed with the exit code."""
631
653
  mcp_cfg = tmp_path / "mcp.json"
@@ -29,9 +29,21 @@ _CHIEF_ONLY = {
29
29
 
30
30
 
31
31
  def _build(monkeypatch, is_chief, captured=None):
32
- async def _fake_ipc(socket_path, agent_id, tool_name, tool_args, session_id):
32
+ # ``delivery_id`` is the 6th positional the provenance closure now passes
33
+ # (#112). Accepting it keeps this stub in step with the real helper — a
34
+ # 5-arg stub raises TypeError inside the tool and surfaces as an opaque
35
+ # ToolError, which reads like a bridge bug rather than a stale stub.
36
+ async def _fake_ipc(
37
+ socket_path, agent_id, tool_name, tool_args, session_id, delivery_id=None
38
+ ):
33
39
  if captured is not None:
34
- captured.append({"tool_name": tool_name, "tool_args": tool_args})
40
+ captured.append(
41
+ {
42
+ "tool_name": tool_name,
43
+ "tool_args": tool_args,
44
+ "delivery_id": delivery_id,
45
+ }
46
+ )
35
47
  return {"ok": True}
36
48
 
37
49
  monkeypatch.setattr(bridge, "_ipc_tool_call", _fake_ipc)
@@ -21,12 +21,16 @@ def _build_staff_server(monkeypatch, captured: list[dict]):
21
21
  """Build a non-chief :class:`FastMCP` with the IPC layer monkeypatched
22
22
  so we can introspect the args ``send_message`` passes downstream."""
23
23
 
24
+ # ``delivery_id`` is the 6th positional the provenance closure now passes
25
+ # (#112). Capturing it keeps this stub in step with the real helper and
26
+ # lets callers assert the daemon-side value actually reaches the IPC layer.
24
27
  async def _fake_ipc_tool_call(
25
28
  socket_path: Path,
26
29
  agent_id: str,
27
30
  tool_name: str,
28
31
  tool_args: dict,
29
32
  session_id: str | None,
33
+ delivery_id: str | None = None,
30
34
  ) -> dict[str, Any]:
31
35
  captured.append(
32
36
  {
@@ -35,6 +39,7 @@ def _build_staff_server(monkeypatch, captured: list[dict]):
35
39
  "tool_name": tool_name,
36
40
  "tool_args": tool_args,
37
41
  "session_id": session_id,
42
+ "delivery_id": delivery_id,
38
43
  }
39
44
  )
40
45
  return {"ok": True}
@@ -108,6 +108,17 @@ async def test_send_agent_status_includes_conversation_id():
108
108
  assert ws.sent[0]["payload"]["conversation_id"] == "conv-1"
109
109
 
110
110
 
111
+ async def test_send_agent_status_includes_delivery_id():
112
+ q, ws = _make_queue()
113
+ await q.send_agent_status(
114
+ agent_id="agent-1",
115
+ status="idle",
116
+ delivery_id="del-1",
117
+ )
118
+
119
+ assert ws.sent[0]["payload"]["delivery_id"] == "del-1"
120
+
121
+
111
122
  async def test_send_tool_call_returns_tool_result():
112
123
  q, ws = _make_queue()
113
124
 
@@ -122,7 +122,7 @@ wheels = [
122
122
 
123
123
  [[package]]
124
124
  name = "cloudagent-daemon"
125
- version = "0.5.1"
125
+ version = "0.5.2"
126
126
  source = { editable = "." }
127
127
  dependencies = [
128
128
  { name = "h2" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.1"