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.
- dingclaw/__init__.py +17 -0
- dingclaw/agent.py +665 -0
- dingclaw/agent_skill/SKILL.md +67 -0
- dingclaw/agent_skill/agents/openai.yaml +7 -0
- dingclaw/agent_templates.py +244 -0
- dingclaw/artifact_check.py +560 -0
- dingclaw/batching.py +143 -0
- dingclaw/blacklist.py +135 -0
- dingclaw/bundle_reader.py +109 -0
- dingclaw/chat_read.py +463 -0
- dingclaw/chat_write.py +399 -0
- dingclaw/cli.py +4008 -0
- dingclaw/compensation.py +486 -0
- dingclaw/compensation_models.py +116 -0
- dingclaw/compensation_state.py +683 -0
- dingclaw/config.py +1545 -0
- dingclaw/config_edit.py +263 -0
- dingclaw/context.py +153 -0
- dingclaw/context_store.py +769 -0
- dingclaw/contract_probe.py +573 -0
- dingclaw/ding_inbox.py +302 -0
- dingclaw/ding_notifier.py +548 -0
- dingclaw/ding_verdict.schema.json +21 -0
- dingclaw/dws_client.py +712 -0
- dingclaw/dws_parser.py +496 -0
- dingclaw/dws_prewarm.py +135 -0
- dingclaw/event_listener.py +584 -0
- dingclaw/event_rearm.py +640 -0
- dingclaw/home_template/AGENTS.md +47 -0
- dingclaw/home_template/IDENTITY.md +27 -0
- dingclaw/home_template/README.md +43 -0
- dingclaw/home_template/SOUL.md +34 -0
- dingclaw/home_template/bin/dingclaw +140 -0
- dingclaw/home_template/knowledge/people.md +40 -0
- dingclaw/home_template/skill/SKILL.md +32 -0
- dingclaw/home_template/skill/references//350/203/275/345/212/233/347/224/273/345/203/217.md +24 -0
- 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
- dingclaw/inbox_skill/SKILL.md +103 -0
- dingclaw/inbox_skill/agents/openai.yaml +7 -0
- dingclaw/inbox_view.py +451 -0
- dingclaw/legacy_delivery_state.py +101 -0
- dingclaw/live_events.py +517 -0
- dingclaw/media.py +584 -0
- dingclaw/message_render.py +306 -0
- dingclaw/models.py +442 -0
- dingclaw/onboarding.py +665 -0
- dingclaw/owner_summon_probe.py +258 -0
- dingclaw/panel_contacts.py +634 -0
- dingclaw/panel_groups.py +397 -0
- dingclaw/panel_write.py +271 -0
- dingclaw/policy.py +187 -0
- dingclaw/preflight.py +356 -0
- dingclaw/presence.py +667 -0
- dingclaw/presence_ack.py +737 -0
- dingclaw/prompt_composer.py +253 -0
- dingclaw/readiness_check.py +211 -0
- dingclaw/reply.schema.json +13 -0
- dingclaw/reply_executor.py +303 -0
- dingclaw/reply_recovery.py +121 -0
- dingclaw/rpc_methods.py +1536 -0
- dingclaw/rpc_schema.py +274 -0
- dingclaw/rpc_server.py +450 -0
- dingclaw/runtime.py +81 -0
- dingclaw/self_command.py +330 -0
- dingclaw/sender.py +784 -0
- dingclaw/service.py +3053 -0
- dingclaw/skill_install.py +285 -0
- dingclaw/state.py +2115 -0
- dingclaw/state_codec.py +171 -0
- dingclaw/state_coordination.py +180 -0
- dingclaw/state_errors.py +25 -0
- dingclaw/state_schema.py +515 -0
- dingclaw/summarize.py +267 -0
- dingclaw/summary.schema.json +21 -0
- dingclaw/tui.py +496 -0
- dingclaw/tui_app/THIRD_PARTY_LICENSES.txt +672 -0
- dingclaw/tui_app/tui.mjs +19625 -0
- dingclaw/typewriter.py +96 -0
- dingclaw/unread_gate.py +275 -0
- dingclaw/warm_agent_pool.py +423 -0
- dingclaw-0.4.1.dist-info/METADATA +521 -0
- dingclaw-0.4.1.dist-info/RECORD +86 -0
- dingclaw-0.4.1.dist-info/WHEEL +5 -0
- dingclaw-0.4.1.dist-info/entry_points.txt +2 -0
- dingclaw-0.4.1.dist-info/licenses/LICENSE +21 -0
- 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 ""
|