dingclaw 0.4.1__py3-none-any.whl

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 (86) hide show
  1. dingclaw/__init__.py +17 -0
  2. dingclaw/agent.py +665 -0
  3. dingclaw/agent_skill/SKILL.md +67 -0
  4. dingclaw/agent_skill/agents/openai.yaml +7 -0
  5. dingclaw/agent_templates.py +244 -0
  6. dingclaw/artifact_check.py +560 -0
  7. dingclaw/batching.py +143 -0
  8. dingclaw/blacklist.py +135 -0
  9. dingclaw/bundle_reader.py +109 -0
  10. dingclaw/chat_read.py +463 -0
  11. dingclaw/chat_write.py +399 -0
  12. dingclaw/cli.py +4008 -0
  13. dingclaw/compensation.py +486 -0
  14. dingclaw/compensation_models.py +116 -0
  15. dingclaw/compensation_state.py +683 -0
  16. dingclaw/config.py +1545 -0
  17. dingclaw/config_edit.py +263 -0
  18. dingclaw/context.py +153 -0
  19. dingclaw/context_store.py +769 -0
  20. dingclaw/contract_probe.py +573 -0
  21. dingclaw/ding_inbox.py +302 -0
  22. dingclaw/ding_notifier.py +548 -0
  23. dingclaw/ding_verdict.schema.json +21 -0
  24. dingclaw/dws_client.py +712 -0
  25. dingclaw/dws_parser.py +496 -0
  26. dingclaw/dws_prewarm.py +135 -0
  27. dingclaw/event_listener.py +584 -0
  28. dingclaw/event_rearm.py +640 -0
  29. dingclaw/home_template/AGENTS.md +47 -0
  30. dingclaw/home_template/IDENTITY.md +27 -0
  31. dingclaw/home_template/README.md +43 -0
  32. dingclaw/home_template/SOUL.md +34 -0
  33. dingclaw/home_template/bin/dingclaw +140 -0
  34. dingclaw/home_template/knowledge/people.md +40 -0
  35. dingclaw/home_template/skill/SKILL.md +32 -0
  36. dingclaw/home_template/skill/references//350/203/275/345/212/233/347/224/273/345/203/217.md +24 -0
  37. dingclaw/home_template/skill/references//350/257/201/346/215/256/344/270/216/346/235/203/351/231/220/351/227/250/347/246/201.md +29 -0
  38. dingclaw/inbox_skill/SKILL.md +103 -0
  39. dingclaw/inbox_skill/agents/openai.yaml +7 -0
  40. dingclaw/inbox_view.py +451 -0
  41. dingclaw/legacy_delivery_state.py +101 -0
  42. dingclaw/live_events.py +517 -0
  43. dingclaw/media.py +584 -0
  44. dingclaw/message_render.py +306 -0
  45. dingclaw/models.py +442 -0
  46. dingclaw/onboarding.py +665 -0
  47. dingclaw/owner_summon_probe.py +258 -0
  48. dingclaw/panel_contacts.py +634 -0
  49. dingclaw/panel_groups.py +397 -0
  50. dingclaw/panel_write.py +271 -0
  51. dingclaw/policy.py +187 -0
  52. dingclaw/preflight.py +356 -0
  53. dingclaw/presence.py +667 -0
  54. dingclaw/presence_ack.py +737 -0
  55. dingclaw/prompt_composer.py +253 -0
  56. dingclaw/readiness_check.py +211 -0
  57. dingclaw/reply.schema.json +13 -0
  58. dingclaw/reply_executor.py +303 -0
  59. dingclaw/reply_recovery.py +121 -0
  60. dingclaw/rpc_methods.py +1536 -0
  61. dingclaw/rpc_schema.py +274 -0
  62. dingclaw/rpc_server.py +450 -0
  63. dingclaw/runtime.py +81 -0
  64. dingclaw/self_command.py +330 -0
  65. dingclaw/sender.py +784 -0
  66. dingclaw/service.py +3053 -0
  67. dingclaw/skill_install.py +285 -0
  68. dingclaw/state.py +2115 -0
  69. dingclaw/state_codec.py +171 -0
  70. dingclaw/state_coordination.py +180 -0
  71. dingclaw/state_errors.py +25 -0
  72. dingclaw/state_schema.py +515 -0
  73. dingclaw/summarize.py +267 -0
  74. dingclaw/summary.schema.json +21 -0
  75. dingclaw/tui.py +496 -0
  76. dingclaw/tui_app/THIRD_PARTY_LICENSES.txt +672 -0
  77. dingclaw/tui_app/tui.mjs +19625 -0
  78. dingclaw/typewriter.py +96 -0
  79. dingclaw/unread_gate.py +275 -0
  80. dingclaw/warm_agent_pool.py +423 -0
  81. dingclaw-0.4.1.dist-info/METADATA +521 -0
  82. dingclaw-0.4.1.dist-info/RECORD +86 -0
  83. dingclaw-0.4.1.dist-info/WHEEL +5 -0
  84. dingclaw-0.4.1.dist-info/entry_points.txt +2 -0
  85. dingclaw-0.4.1.dist-info/licenses/LICENSE +21 -0
  86. dingclaw-0.4.1.dist-info/top_level.txt +1 -0
dingclaw/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """DingTalk digital-avatar runtime with fail-closed delivery controls."""
2
+
3
+ from .models import AgentResult, ConversationType, DeliveryStatus, DwsMessage, MessageStatus, PollResult
4
+
5
+ __all__ = [
6
+ "AgentResult",
7
+ "ConversationType",
8
+ "DeliveryStatus",
9
+ "DwsMessage",
10
+ "MessageStatus",
11
+ "PollResult",
12
+ ]
13
+
14
+ # Kept in step with pyproject by a test. It is not decoration: the panel
15
+ # reports it at handshake, and a version that lies about which build is running
16
+ # turns "reinstall and see" into the only available diagnostic.
17
+ __version__ = "0.4.1"
dingclaw/agent.py ADDED
@@ -0,0 +1,665 @@
1
+ """Agent request contract, subprocess adapter, and safe fallback routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import os
8
+ import pwd
9
+ import resource
10
+ import re
11
+ import signal
12
+ import subprocess
13
+ import tempfile
14
+ import threading
15
+ from functools import partial
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Iterable, Mapping, Optional, Protocol, Sequence
20
+
21
+ from .models import AgentAction, AgentResult, QuotedRef
22
+ from .prompt_composer import PromptBudget, PromptBudgetError, compose_prompt
23
+
24
+
25
+ class AgentError(RuntimeError):
26
+ """Base class for adapter failures."""
27
+
28
+
29
+ class AgentTransientError(AgentError):
30
+ """Infrastructure failure for which another configured adapter may run."""
31
+
32
+
33
+ class AgentOutputError(AgentError):
34
+ """Malformed model output for which another configured adapter may run."""
35
+
36
+
37
+ class AgentUnavailableError(AgentError):
38
+ """All configured adapters failed at the infrastructure/output layer."""
39
+
40
+
41
+ class WarmAgentTimeout(AgentTransientError):
42
+ """A pre-warmed process ran its whole timeout without answering.
43
+
44
+ Transient by inheritance, and that is the whole design: warm and cold
45
+ are the same model call, so a warm timeout is *the* attempt rather than
46
+ a reason to spend the timeout again cold. Being an AgentTransientError
47
+ means it already routes exactly like a cold timeout -- next adapter, and
48
+ failing that, the ordinary deferred retry.
49
+ """
50
+
51
+
52
+ # Local subprocess-input guardrail, far below model context. Sized for the v2
53
+ # byte-budgeted composer (skill 48k + batch 24k + context 27k + frame 3k, all
54
+ # UTF-8 bytes) so a fully CJK prompt can never trip the limit spuriously.
55
+ _MAX_PROMPT_BYTES = 120_000
56
+
57
+
58
+ # Every live agent child, registered at spawn and dropped at completion.
59
+ # Children run with start_new_session=True, which is exactly what makes a
60
+ # terminal SIGTERM unable to reach them: without this registry, a daemon
61
+ # restart (`launchctl kickstart -k` on every deploy) leaves an orphan claude
62
+ # finishing a paid model call whose output has nowhere to go. Shutdown walks
63
+ # this set and kills the process groups instead.
64
+ _live_children_lock = threading.Lock()
65
+ # Keyed by id() rather than held in a set: Popen is unhashable in some test
66
+ # doubles, and identity is exactly the semantics wanted here anyway.
67
+ _live_children: dict[int, subprocess.Popen] = {}
68
+
69
+
70
+ def track_agent_child(process: subprocess.Popen) -> None:
71
+ with _live_children_lock:
72
+ _live_children[id(process)] = process
73
+
74
+
75
+ def untrack_agent_child(process: subprocess.Popen) -> None:
76
+ with _live_children_lock:
77
+ _live_children.pop(id(process), None)
78
+
79
+
80
+ def kill_tracked_agent_children() -> int:
81
+ """Kill every registered agent process group; the shutdown last resort.
82
+
83
+ Returns how many were killed, for the shutdown log. Safe against races
84
+ with normal completion: an already-dead child is a no-op, and untrack
85
+ after this walk pops something already gone.
86
+
87
+ SIGKILL outright, no SIGTERM ladder: these children owe no cleanup, and
88
+ the graceful path costs up to four seconds each -- three of them would
89
+ eat the whole launchd ExitTimeOut on their own. Kill them all first,
90
+ reap afterwards, so the cost is one signal sweep plus one wait sweep.
91
+ """
92
+ with _live_children_lock:
93
+ children = list(_live_children.values())
94
+ _live_children.clear()
95
+ for child in children:
96
+ try:
97
+ os.killpg(child.pid, signal.SIGKILL)
98
+ except (ProcessLookupError, PermissionError, OSError):
99
+ pass
100
+ for child in children:
101
+ try:
102
+ child.wait(timeout=1)
103
+ except Exception:
104
+ pass
105
+ return len(children)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ContextTurn:
110
+ """One transcript line as the prompt sees it."""
111
+
112
+ role: str
113
+ at: str
114
+ sender: str
115
+ text: str
116
+ quoted: str = ""
117
+
118
+ def as_payload(self) -> dict[str, str]:
119
+ payload = {"role": self.role, "at": self.at, "text": self.text}
120
+ if self.sender:
121
+ payload["sender"] = self.sender
122
+ if self.quoted:
123
+ payload["quoted"] = self.quoted
124
+ return payload
125
+
126
+ def size_bytes(self) -> int:
127
+ return len(
128
+ (self.role + self.at + self.sender + self.text + self.quoted).encode(
129
+ "utf-8"
130
+ )
131
+ )
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class AgentContext:
136
+ """Advisory conversation context; carried inside the untrusted JSON."""
137
+
138
+ memory: tuple[tuple[str, str], ...] = ()
139
+ summary: str = ""
140
+ recent_turns: tuple[ContextTurn, ...] = ()
141
+ # Turns after the agent's own last utterance — what it has not answered
142
+ # yet. Kept separate so the model never reads them as already handled.
143
+ since_last_reply: tuple[ContextTurn, ...] = ()
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class AgentRequest:
148
+ message_ids: tuple[str, ...]
149
+ conversation_id: str
150
+ conversation_type: str
151
+ sender_id: str
152
+ sender_name: str
153
+ text: str
154
+ skill_hash: str
155
+ tier: str = "restricted"
156
+ context: Optional[AgentContext] = None
157
+ quoted: tuple[Optional[QuotedRef], ...] = ()
158
+ # A steering instruction from the owner, typed into the panel when they
159
+ # triggered this reply by hand. Unlike everything in the message JSON it
160
+ # is trusted input, so it is rendered in the instruction half of the
161
+ # prompt -- but it steers wording only: the tier, the tools and the send
162
+ # gates are all unchanged by it.
163
+ owner_note: str = ""
164
+ # The wall clock at request build, ISO8601 with offset. The model runs
165
+ # with no tools and no other clock, so without this it guesses "now" from
166
+ # the newest transcript timestamp it can see -- measured 13 minutes stale
167
+ # in production. Rendered in the trusted frame; deliberately excluded
168
+ # from the request hash so retrying the same batch stays one job.
169
+ now: str = ""
170
+
171
+
172
+ class AgentAdapter(Protocol):
173
+ name: str
174
+
175
+ def generate(self, request: AgentRequest) -> AgentResult:
176
+ ...
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class SubprocessRunResult:
181
+ returncode: int
182
+ stdout: str
183
+ stderr: str
184
+
185
+
186
+ def run_agent_subprocess(
187
+ *,
188
+ name: str,
189
+ command: Sequence[str],
190
+ prompt: str,
191
+ timeout_seconds: int,
192
+ max_output_bytes: int,
193
+ extra_env_keys: tuple[str, ...] = (),
194
+ env_overrides: Optional[Mapping[str, str]] = None,
195
+ cwd: Optional[Path | str] = None,
196
+ ) -> SubprocessRunResult:
197
+ """Shared bounded subprocess execution for reply and maintenance agents."""
198
+ prompt_bytes = prompt.encode()
199
+ if len(prompt_bytes) > _MAX_PROMPT_BYTES:
200
+ raise AgentOutputError(f"{name} prompt exceeded byte limit")
201
+ # Popen reports a missing cwd as a bare FileNotFoundError naming neither
202
+ # the agent nor which of the two paths -- binary or directory -- was the
203
+ # one absent. Checking here keeps the workspace's own failure legible.
204
+ #
205
+ # Transient, deliberately unlike the ValueError an unpermitted environment
206
+ # override raises: a workspace can go missing for reasons outside this
207
+ # code, so the router should stay free to try the next adapter, whereas an
208
+ # override key is written at the call site and can only be wrong because
209
+ # the code is.
210
+ if cwd is not None and not os.path.isdir(cwd):
211
+ raise AgentTransientError(f"{name} working directory is missing: {cwd}")
212
+ with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
213
+ try:
214
+ process = subprocess.Popen(
215
+ list(command),
216
+ stdin=subprocess.PIPE,
217
+ stdout=stdout_file,
218
+ stderr=stderr_file,
219
+ start_new_session=True,
220
+ # None means "inherit", which is what every caller got before
221
+ # this parameter existed. A caller that does pass one is
222
+ # bounding the blast radius: the privileged template runs with
223
+ # bypassPermissions, so wherever the daemon happens to sit --
224
+ # in production, a git checkout -- is otherwise the directory
225
+ # the agent is loose in, while `--add-dir {agent_workspace}`
226
+ # says the workspace was always the intended place to work.
227
+ cwd=cwd,
228
+ env=agent_environment(name, extra_env_keys, env_overrides),
229
+ preexec_fn=partial(limit_child_output, max_output_bytes),
230
+ )
231
+ except OSError as error:
232
+ raise AgentTransientError(f"{name} unavailable: {error}") from error
233
+ track_agent_child(process)
234
+ try:
235
+ returned_stdout, returned_stderr = process.communicate(
236
+ input=prompt_bytes, timeout=timeout_seconds
237
+ )
238
+ except subprocess.TimeoutExpired as error:
239
+ terminate_process_group(process)
240
+ raise AgentTransientError(f"{name} timeout") from error
241
+ finally:
242
+ untrack_agent_child(process)
243
+ stdout = _bounded_output(returned_stdout, stdout_file, max_output_bytes)
244
+ stderr = _bounded_output(returned_stderr, stderr_file, max_output_bytes)
245
+ if len(stdout) > max_output_bytes or len(stderr) > max_output_bytes:
246
+ raise AgentOutputError(f"{name} output exceeded byte limit")
247
+ stdout_text = stdout.decode("utf-8", errors="replace")
248
+ stderr_text = stderr.decode("utf-8", errors="replace")
249
+ if process.returncode != 0:
250
+ digest = hashlib.sha256((stderr or stdout)).hexdigest()[:16]
251
+ raise AgentTransientError(
252
+ f"{name} exited {process.returncode}; output_sha256={digest}"
253
+ )
254
+ return SubprocessRunResult(process.returncode, stdout_text, stderr_text)
255
+
256
+
257
+ @dataclass(frozen=True)
258
+ class SubprocessAgentAdapter:
259
+ name: str
260
+ command: Sequence[str]
261
+ skill_instructions: str
262
+ timeout_seconds: int = 120
263
+ max_output_bytes: int = 1_000_000
264
+ instruction_resolver: Optional[Callable[[AgentRequest], str]] = None
265
+ prompt_budget: Optional[PromptBudget] = None
266
+ extra_env_keys: tuple[str, ...] = ()
267
+ env_overrides: Mapping[str, str] = field(default_factory=dict)
268
+ cwd: Optional[Path | str] = None
269
+ owner_display_name: str = ""
270
+ # A WarmAgentPool, duck-typed to avoid the import cycle (the pool imports
271
+ # this module's process helpers). None means what it always meant: every
272
+ # call pays the cold start.
273
+ warm_pool: Any = None
274
+
275
+ def generate(self, request: AgentRequest) -> AgentResult:
276
+ instructions = (
277
+ self.instruction_resolver(request)
278
+ if self.instruction_resolver is not None
279
+ else self.skill_instructions
280
+ )
281
+ try:
282
+ prompt = compose_prompt(
283
+ request,
284
+ instructions,
285
+ budget=self.prompt_budget,
286
+ owner_display_name=self.owner_display_name,
287
+ )
288
+ except PromptBudgetError as error:
289
+ raise AgentOutputError(f"{self.name}: {error}") from error
290
+ warm = self._generate_warm(prompt)
291
+ if warm is not None:
292
+ # The warm result string is the same schema JSON the cold
293
+ # envelope unwraps to, so it goes through the identical parse
294
+ # and validation. A malformed one raises AgentOutputError
295
+ # exactly as a malformed cold output would.
296
+ return _parse_agent_output(self.name, warm)
297
+ run = run_agent_subprocess(
298
+ name=self.name,
299
+ command=self.command,
300
+ prompt=prompt,
301
+ timeout_seconds=self.timeout_seconds,
302
+ max_output_bytes=self.max_output_bytes,
303
+ extra_env_keys=self.extra_env_keys,
304
+ env_overrides=self.env_overrides,
305
+ cwd=self.cwd,
306
+ )
307
+ return _parse_agent_output(self.name, run.stdout)
308
+
309
+ def _generate_warm(self, prompt: str) -> Optional[str]:
310
+ """A result from the warm pool, or None meaning "go cold".
311
+
312
+ Every *fast* pool failure degrades silently: warm exists to shave the
313
+ process cold start, never to become a second way to lose a reply.
314
+ The exception is a warm process that ran its whole timeout -- warm
315
+ and cold are the same model call, so re-running it cold would spend
316
+ the timeout twice on an answer that is not coming either time. That
317
+ one is reported as the transient failure a cold timeout would raise,
318
+ and the ordinary retry machinery handles it from there.
319
+ """
320
+ if self.warm_pool is None:
321
+ return None
322
+ try:
323
+ return self.warm_pool.generate(
324
+ command=self.command,
325
+ prompt=prompt,
326
+ timeout_seconds=self.timeout_seconds,
327
+ env=agent_environment(
328
+ self.name, self.extra_env_keys, self.env_overrides
329
+ ),
330
+ cwd=str(self.cwd) if self.cwd is not None else None,
331
+ )
332
+ except WarmAgentTimeout:
333
+ raise
334
+ except Exception: # noqa: BLE001 - any other warm fault goes cold
335
+ return None
336
+
337
+
338
+ class AgentRouter:
339
+ def __init__(self, adapters: Iterable[AgentAdapter]) -> None:
340
+ self.adapters = list(adapters)
341
+ if not self.adapters:
342
+ raise ValueError("at least one agent adapter is required")
343
+
344
+ def generate(self, request: AgentRequest) -> AgentResult:
345
+ failures: list[str] = []
346
+ for adapter in self.adapters:
347
+ try:
348
+ result = adapter.generate(request)
349
+ _validate_result(result)
350
+ return result
351
+ except (AgentTransientError, AgentOutputError) as error:
352
+ failures.append(f"{adapter.name}: {error}")
353
+ raise AgentUnavailableError("all agent adapters failed: " + "; ".join(failures))
354
+
355
+
356
+ @dataclass(frozen=True)
357
+ class BudgetedAgent:
358
+ fallback: AgentAdapter
359
+ budget_store: Any
360
+ daily_limit: int
361
+ budget_key_prefix: str = ""
362
+
363
+ @property
364
+ def name(self) -> str:
365
+ return "daily-budget"
366
+
367
+ def generate(self, request: AgentRequest) -> AgentResult:
368
+ day = datetime.now(timezone.utc).date().isoformat()
369
+ if self.budget_store.try_consume_agent_budget(
370
+ f"{self.budget_key_prefix}{day}", self.daily_limit
371
+ ):
372
+ return self.fallback.generate(request)
373
+ return AgentResult(
374
+ action=AgentAction.REPLY,
375
+ reply="收到。当前自动处理额度已达上限,本条消息未进入智能体处理。",
376
+ domain="local",
377
+ risk="low",
378
+ confidence=1.0,
379
+ reason="daily_agent_budget_exhausted",
380
+ )
381
+
382
+
383
+ @dataclass(frozen=True)
384
+ class PrivilegedReplyGuard:
385
+ """Minimal wrapper for the privileged chain: only breaks the command echo
386
+ loop (a reply that looks like an owner command would be parsed as one next
387
+ round, because our own sends carry a self identity)."""
388
+
389
+ fallback: AgentAdapter
390
+ command_prefixes: tuple[str, ...] = ()
391
+
392
+ @property
393
+ def name(self) -> str:
394
+ return "privileged-guard"
395
+
396
+ def generate(self, request: AgentRequest) -> AgentResult:
397
+ result = self.fallback.generate(request)
398
+ if result.action is AgentAction.REPLY and _reply_looks_like_command(
399
+ result.reply, self.command_prefixes
400
+ ):
401
+ return AgentResult(
402
+ action=AgentAction.REPLY,
403
+ reply="收到。生成结果包含指令形内容,已替换为本条安全回复。",
404
+ domain=result.domain or "control",
405
+ risk="medium",
406
+ confidence=1.0,
407
+ reason="local_reply_policy_reply_looks_like_command",
408
+ )
409
+ return result
410
+
411
+
412
+ @dataclass(frozen=True)
413
+ class LocalFirstAgent:
414
+ fallback: AgentAdapter
415
+ command_prefixes: tuple[str, ...] = ()
416
+
417
+ @property
418
+ def name(self) -> str:
419
+ return "local-first"
420
+
421
+ def generate(self, request: AgentRequest) -> AgentResult:
422
+ text = request.text.strip().casefold()
423
+ if text in {"你好", "您好", "在吗", "hi", "hello", "ping"}:
424
+ return AgentResult(
425
+ action=AgentAction.REPLY,
426
+ reply="在的,请说。",
427
+ domain="local",
428
+ risk="low",
429
+ confidence=1.0,
430
+ reason="local_greeting_template",
431
+ )
432
+ if _asks_for_realtime_status(text):
433
+ return AgentResult(
434
+ action=AgentAction.REPLY,
435
+ reply=(
436
+ "收到。此消息涉及实时状态,当前自动回复没有查询权限;"
437
+ "我不会给出未经核实的结论,请以对应系统记录或人工确认为准。"
438
+ ),
439
+ domain="local",
440
+ risk="medium",
441
+ confidence=1.0,
442
+ reason="realtime_status_requires_verification",
443
+ )
444
+ result = self.fallback.generate(request)
445
+ unsafe_reply_reason = _unsafe_reply_reason(
446
+ result.reply, self.command_prefixes
447
+ )
448
+ if result.action is AgentAction.REPLY and unsafe_reply_reason:
449
+ return AgentResult(
450
+ action=AgentAction.REPLY,
451
+ reply="收到。这个问题需要进一步核实或人工处理,我不会直接给出未经确认的结论。",
452
+ domain=result.domain or "local",
453
+ risk="medium",
454
+ confidence=1.0,
455
+ reason=f"local_reply_policy_{unsafe_reply_reason}",
456
+ )
457
+ if result.risk.casefold() == "high" or result.confidence < 0.5:
458
+ return AgentResult(
459
+ action=AgentAction.REPLY,
460
+ reply="收到。这个问题需要进一步核实或人工处理,我不会直接给出未经确认的结论。",
461
+ domain=result.domain or "local",
462
+ risk="medium",
463
+ confidence=1.0,
464
+ reason="local_high_risk_escalation",
465
+ )
466
+ return result
467
+
468
+
469
+ def parse_structured_payload(agent_name: str, output: str) -> Mapping[str, Any]:
470
+ """Unwrap a CLI agent's stdout into the structured object it produced.
471
+
472
+ Handles Codex JSONL event streams, Claude --print envelopes whose
473
+ ``result`` is a JSON string, and ``structured_output`` wrappers.
474
+ """
475
+ text = output.strip()
476
+ if agent_name == "codex":
477
+ text = _last_codex_message(text) or text
478
+ try:
479
+ payload = json.loads(text)
480
+ except json.JSONDecodeError as error:
481
+ raise AgentOutputError(f"{agent_name} returned invalid JSON") from error
482
+ if isinstance(payload, Mapping) and isinstance(payload.get("result"), str):
483
+ try:
484
+ payload = json.loads(payload["result"])
485
+ except json.JSONDecodeError:
486
+ pass
487
+ if isinstance(payload, Mapping) and isinstance(payload.get("structured_output"), Mapping):
488
+ payload = payload["structured_output"]
489
+ if not isinstance(payload, Mapping):
490
+ raise AgentOutputError(f"{agent_name} result must be an object")
491
+ return payload
492
+
493
+
494
+ def _parse_agent_output(agent_name: str, output: str) -> AgentResult:
495
+ payload = parse_structured_payload(agent_name, output)
496
+ try:
497
+ result = AgentResult(
498
+ action=AgentAction(str(payload["action"]).lower()),
499
+ reply=str(payload.get("reply", "")),
500
+ domain=str(payload.get("domain", "")),
501
+ risk=str(payload.get("risk", "")),
502
+ confidence=float(payload["confidence"]),
503
+ reason=str(payload.get("reason", "")),
504
+ )
505
+ except (KeyError, TypeError, ValueError) as error:
506
+ raise AgentOutputError(f"{agent_name} result has invalid fields") from error
507
+ _validate_result(result)
508
+ return result
509
+
510
+
511
+ def _last_codex_message(output: str) -> str:
512
+ messages: list[str] = []
513
+ for line in output.splitlines():
514
+ try:
515
+ event: Any = json.loads(line)
516
+ except json.JSONDecodeError:
517
+ continue
518
+ if not isinstance(event, Mapping):
519
+ continue
520
+ item = event.get("item")
521
+ if isinstance(item, Mapping) and item.get("type") == "agent_message":
522
+ value = item.get("text")
523
+ if isinstance(value, str):
524
+ messages.append(value)
525
+ return messages[-1] if messages else ""
526
+
527
+
528
+ def _validate_result(result: AgentResult) -> None:
529
+ if not 0 <= result.confidence <= 1:
530
+ raise AgentOutputError("confidence must be between 0 and 1")
531
+ if result.action is AgentAction.REPLY and not result.reply.strip():
532
+ raise AgentOutputError("reply action requires non-empty reply")
533
+ if len(result.reply) > 1500:
534
+ raise AgentOutputError("reply exceeds 1500 characters")
535
+ if result.action is AgentAction.SKIP and result.reply.strip():
536
+ raise AgentOutputError("skip action must not include reply")
537
+ if result.risk not in {"low", "medium", "high"}:
538
+ raise AgentOutputError("risk must be low, medium, or high")
539
+ if not result.domain.strip() or not result.reason.strip():
540
+ raise AgentOutputError("domain and reason must not be empty")
541
+
542
+
543
+ def agent_environment(
544
+ agent_name: str,
545
+ extra_env_keys: tuple[str, ...] = (),
546
+ overrides: Optional[Mapping[str, str]] = None,
547
+ ) -> dict[str, str]:
548
+ """The environment a child agent is allowed to see. Public because the
549
+ media reader spawns children too, and a second hand-rolled copy of this
550
+ allowlist would drift from this one."""
551
+ allowed = {
552
+ "PATH",
553
+ "HOME",
554
+ "USER",
555
+ "HTTP_PROXY",
556
+ "HTTPS_PROXY",
557
+ "NO_PROXY",
558
+ "LANG",
559
+ "LC_ALL",
560
+ "TMPDIR",
561
+ }
562
+ if agent_name.casefold() == "codex":
563
+ allowed.add("CODEX_HOME")
564
+ allowed.update(extra_env_keys)
565
+ environment = {key: value for key, value in os.environ.items() if key in allowed}
566
+ # An override is bound by the same allowlist as an inherited value. The
567
+ # allowlist exists so that one place answers "what can a child see"; if a
568
+ # caller could name any variable, that answer would spread to every call
569
+ # site, and one of them naming the wrong variable would become a way to
570
+ # inject arbitrary environment into a subprocess we spawn with the
571
+ # operator's own credentials.
572
+ #
573
+ # A plain ValueError, deliberately unlike the AgentTransientError a missing
574
+ # working directory raises: callers write these keys literally, so an
575
+ # unpermitted one is a defect here rather than an environment the router
576
+ # should route around by falling back to the next adapter.
577
+ for key, value in (overrides or {}).items():
578
+ if key not in allowed:
579
+ raise ValueError(f"environment override not permitted: {key}")
580
+ # Empty means "leave it alone", so a caller can pass an unset config
581
+ # value straight through instead of branching on every key.
582
+ if value:
583
+ environment[key] = value
584
+ if not environment.get("USER"):
585
+ environment["USER"] = pwd.getpwuid(os.getuid()).pw_name
586
+ return environment
587
+
588
+
589
+ def terminate_process_group(process: subprocess.Popen[bytes]) -> None:
590
+ try:
591
+ os.killpg(process.pid, signal.SIGTERM)
592
+ process.wait(timeout=2)
593
+ except (ProcessLookupError, subprocess.TimeoutExpired):
594
+ try:
595
+ os.killpg(process.pid, signal.SIGKILL)
596
+ except ProcessLookupError:
597
+ pass
598
+ process.wait(timeout=2)
599
+
600
+
601
+ def _asks_for_realtime_status(text: str) -> bool:
602
+ status_words = ("发布", "上线", "部署", "进展", "状态", "完成", "修好", "合并")
603
+ question_words = (
604
+ "吗",
605
+ "没",
606
+ "没有",
607
+ "是否",
608
+ "怎样",
609
+ "怎么",
610
+ "如何",
611
+ "?",
612
+ "?",
613
+ )
614
+ return any(word in text for word in status_words) and any(
615
+ word in text for word in question_words
616
+ )
617
+
618
+
619
+ def limit_child_output(max_output_bytes: int) -> None:
620
+ current_soft, current_hard = resource.getrlimit(resource.RLIMIT_FSIZE)
621
+ limit = max_output_bytes + 1
622
+ if current_hard != resource.RLIM_INFINITY:
623
+ limit = min(limit, current_hard)
624
+ resource.setrlimit(resource.RLIMIT_FSIZE, (limit, limit))
625
+
626
+
627
+ def _bounded_output(
628
+ returned: Optional[bytes], output_file: Any, max_output_bytes: int
629
+ ) -> bytes:
630
+ if returned is not None:
631
+ return returned
632
+ output_file.seek(0)
633
+ return output_file.read(max_output_bytes + 1)
634
+
635
+
636
+ def _reply_looks_like_command(reply: str, command_prefixes: tuple[str, ...]) -> bool:
637
+ stripped = reply.strip()
638
+ return any(
639
+ prefix and stripped.startswith(prefix) for prefix in command_prefixes
640
+ )
641
+
642
+
643
+ def _unsafe_reply_reason(reply: str, command_prefixes: tuple[str, ...] = ()) -> str:
644
+ if _reply_looks_like_command(reply, command_prefixes):
645
+ return "reply_looks_like_command"
646
+ if re.search(r"https?://", reply, flags=re.IGNORECASE):
647
+ return "link"
648
+ if "@" in reply:
649
+ return "mention"
650
+ if re.search(
651
+ r"(?:sk-[a-z0-9_-]{8,}|client[_-]?secret|access[_-]?token|api[_-]?key|password\s*[:=])",
652
+ reply,
653
+ flags=re.IGNORECASE,
654
+ ):
655
+ return "credential_pattern"
656
+ if re.search(
657
+ r"(?:已|已经|刚刚).{0,4}(?:发布|上线|部署|合并|审批|完成|修复|处理)|"
658
+ r"(?:发布|上线|部署|合并|审批).{0,8}(?:完成|成功|通过)|"
659
+ r"我(?:会|马上|现在).{0,4}(?:发布|上线|部署|合并|审批)",
660
+ reply,
661
+ ):
662
+ return "unverified_action_claim"
663
+ if "# Source:" in reply or "个人 skill" in reply:
664
+ return "internal_context"
665
+ return ""