mtsa-agent 0.3.0__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.
mtsa/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """通用时序分析引擎:与场景解耦的导入、质量、预处理、指标、频域、检验、异常、图表与报告。"""
mtsa/agent_guards.py ADDED
@@ -0,0 +1,142 @@
1
+ # -*- coding: utf-8 -*-
2
+ """决策安全边界(T2.4):模型能选什么、不能改什么、越界几次后停下。
3
+
4
+ 设计原则
5
+ --------
6
+ 1. **边界独立于运行时**。运行时只负责循环;「动作是否合法」由本模块判断,
7
+ 这样规则可以被单独测试,也不会随着提示词改动而漂移。
8
+ 2. **拒绝必须有理由,且理由要回灌模型**。只返回 False 会让模型反复尝试同一动作。
9
+ 3. **拒绝要留痕**。每次越界记录 kind、动作、步序与说明,供报告与后续指标统计。
10
+ 4. **失败可重试一次**。工具失败可能是瞬时的;但成功或 skipped 的工具不得重复,
11
+ 否则会白白消耗步数与 token。
12
+
13
+ 边界只覆盖「选择」:不改参数、不改数据、不改计算。锁定的标定参数直接拒绝,
14
+ 未知参数也拒绝——静默忽略会让模型误以为参数生效。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Mapping, Optional, Sequence
20
+
21
+ from .planner import PARAM_LOCKED
22
+
23
+ # 越界类型。报告与指标按这个分类统计,不靠自由文本解析。
24
+ VIOLATION_TOOL_NOT_ALLOWED = "tool_not_allowed"
25
+ VIOLATION_DUPLICATE = "duplicate_tool"
26
+ VIOLATION_LOCKED_PARAM = "locked_parameter"
27
+ VIOLATION_UNKNOWN_ARG = "unknown_argument"
28
+ VIOLATION_STEP_BUDGET = "step_budget"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class GuardPolicy:
33
+ """一次运行的硬边界。"""
34
+
35
+ allowed_tools: tuple[str, ...]
36
+ max_steps: int = 8
37
+ max_consecutive_violations: int = 3
38
+ # 失败工具允许重试的次数。成功或 skipped 的工具不允许重复。
39
+ max_retries_per_tool: int = 1
40
+ locked_parameters: frozenset[str] = field(default_factory=lambda: frozenset(PARAM_LOCKED))
41
+ # 当前工具全部从计划取参数,因此默认不接受任何入参。
42
+ accepts_args: bool = False
43
+
44
+
45
+ @dataclass
46
+ class GuardDecision:
47
+ allowed: bool
48
+ kind: str = "ok"
49
+ detail: str = ""
50
+ is_retry: bool = False
51
+
52
+
53
+ class ActionGuard:
54
+ """按策略检查动作,并累计越界与重试统计。"""
55
+
56
+ def __init__(self, policy: GuardPolicy) -> None:
57
+ self.policy = policy
58
+ self.violations: list[dict[str, Any]] = []
59
+ self._consecutive = 0
60
+ self._retries: dict[str, int] = {}
61
+ self._ok: dict[str, int] = {}
62
+
63
+ @property
64
+ def consecutive_violations(self) -> int:
65
+ return self._consecutive
66
+
67
+ def retry_count(self, tool: str) -> int:
68
+ """某个工具已用掉的重试次数(运行时判断「已失败且无法再试」时需要)。"""
69
+ return self._retries.get(tool, 0)
70
+
71
+ def check(self, action: str, args: Mapping[str, Any],
72
+ results: Mapping[str, Any], step_index: int) -> GuardDecision:
73
+ """判断动作是否可执行。results 为 tool -> ToolResult(含 outcome)。"""
74
+ if step_index >= self.policy.max_steps:
75
+ return GuardDecision(False, VIOLATION_STEP_BUDGET,
76
+ f"已达步数上限 {self.policy.max_steps},只能结束")
77
+
78
+ if action not in self.policy.allowed_tools:
79
+ return GuardDecision(
80
+ False, VIOLATION_TOOL_NOT_ALLOWED,
81
+ f"工具 {action} 不在本场景白名单中,可选:{list(self.policy.allowed_tools)}")
82
+
83
+ params = dict(args or {})
84
+ locked = sorted(set(params) & set(self.policy.locked_parameters))
85
+ if locked:
86
+ return GuardDecision(
87
+ False, VIOLATION_LOCKED_PARAM,
88
+ f"参数 {locked} 属于已标定参数,禁止模型修改;如需调整请用 --param 显式覆盖")
89
+ if params and not self.policy.accepts_args:
90
+ return GuardDecision(
91
+ False, VIOLATION_UNKNOWN_ARG,
92
+ f"{action} 不接受参数,实际收到 {sorted(params)}")
93
+
94
+ prev = results.get(action)
95
+ if prev is not None:
96
+ if getattr(prev, "outcome", None) == "failed":
97
+ used = self._retries.get(action, 0)
98
+ if used < self.policy.max_retries_per_tool:
99
+ return GuardDecision(True, "retry", f"{action} 上次失败,允许重试一次",
100
+ is_retry=True)
101
+ return GuardDecision(
102
+ False, VIOLATION_DUPLICATE,
103
+ f"{action} 已重试 {used} 次仍失败,不得继续重复调用")
104
+ return GuardDecision(
105
+ False, VIOLATION_DUPLICATE,
106
+ f"{action} 已执行过({getattr(prev, 'outcome', 'ok')}),不得重复调用")
107
+
108
+ return GuardDecision(True)
109
+
110
+ def record_violation(self, decision: GuardDecision, action: str, step_index: int) -> None:
111
+ self._consecutive += 1
112
+ self.violations.append({
113
+ "step": step_index, "action": action,
114
+ "kind": decision.kind, "detail": decision.detail,
115
+ })
116
+
117
+ def record_execution(self, action: str, outcome: str, is_retry: bool = False) -> None:
118
+ if is_retry:
119
+ self._retries[action] = self._retries.get(action, 0) + 1
120
+ if outcome in ("ok", "skipped"):
121
+ self._consecutive = 0
122
+ self._ok[action] = self._ok.get(action, 0) + 1
123
+
124
+ def stats(self) -> dict[str, Any]:
125
+ by_kind: dict[str, int] = {}
126
+ for v in self.violations:
127
+ by_kind[v["kind"]] = by_kind.get(v["kind"], 0) + 1
128
+ return {
129
+ "violations": len(self.violations),
130
+ "by_kind": by_kind,
131
+ "consecutive_violations": self._consecutive,
132
+ "retries": dict(self._retries),
133
+ "ok_executions": dict(self._ok),
134
+ "policy": {
135
+ "allowed_tools": list(self.policy.allowed_tools),
136
+ "max_steps": self.policy.max_steps,
137
+ "max_consecutive_violations": self.policy.max_consecutive_violations,
138
+ "max_retries_per_tool": self.policy.max_retries_per_tool,
139
+ "locked_parameters": sorted(self.policy.locked_parameters),
140
+ "accepts_args": self.policy.accepts_args,
141
+ },
142
+ }
mtsa/agent_runtime.py ADDED
@@ -0,0 +1,527 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Agent 运行时:观察—决策—行动循环。
3
+
4
+ 与计划层的区别
5
+ --------------
6
+ M1 的计划层是**一次性规划**:看完数据摘要就定下 12 个分析项,之后代码按固定
7
+ 顺序执行,模型看不到任何中间结果。本模块改为**逐步决策**:每执行完一个工具,
8
+ 就把结果摘要回灌给模型,由它决定下一步做什么,或宣布结束。
9
+
10
+ 这才是「流程长度由模型决定」的落点。计划层仍在准备阶段产出基底计划
11
+ (场景、目标列、参数),但分析层走哪几个工具、什么顺序,由这里的循环决定。
12
+
13
+ 边界
14
+ ----
15
+ 1. **只编排白名单内的分析工具**。数据管道(导入至清洗)与收尾(图表至报告)不进
16
+ 循环:前者有严格前置依赖,后者需聚合全部结果。本轮天气场景白名单为五个工具,
17
+ 股票专属的 `check_lookahead` 不在其中。
18
+ 2. **数值不经模型**。模型只看到 `ToolResult.summary`,看不到 `data`。
19
+ 3. **失败可重试一次**。工具失败后,模型可以在下一步重试同一工具;成功或 skipped
20
+ 的工具不得重复。边界由 `agent_guards.ActionGuard` 判定并留痕。
21
+
22
+ 终止条件
23
+ --------
24
+ | 原因 | 触发 |
25
+ | --- | --- |
26
+ | `model_finished` | 模型输出 `finish` |
27
+ | `max_steps` | 步数达上限 |
28
+ | `idle_loop` | 连续被拒次数达上限(反复选已执行或无效工具) |
29
+ | `decision_error` | 模型输出连续无法解析 |
30
+ | `scope_unmet` | guided 模式下反复要求结束,而下限始终未覆盖 |
31
+ | `budget_exhausted` | guided 模式下限之外的追加步数已用尽,模型仍要求追加 |
32
+ | `token_budget` | 累计 token 达到 `agent_max_tokens`,在下次模型调用前停止 |
33
+
34
+ 范围下限(T2.11)
35
+ ----------------
36
+ `guided`(默认)先用关键词把「这个问题至少要做什么」算成下限,再让模型决定顺序:
37
+
38
+ - 下限未覆盖时 `finish` 被拒绝(`scope_incomplete`),并告知缺哪几个工具;
39
+ - 下限之外最多追加 `max_extra_steps`(默认 1)步,超出记 `scope_exceeded` 并结束(不计入越界);
40
+ - 下限工具若失败且重试用尽,允许结束——否则会空转到 `max_steps`,把
41
+ 「做不到」伪装成「没做完」。
42
+
43
+ `free` 保留 T2.2 的原始语义(范围完全由模型判断)。两种模式的步数分布都在
44
+ T2.11 里实测,没有用推断代替测量。
45
+ """
46
+ from __future__ import annotations
47
+
48
+ from dataclasses import dataclass, field
49
+ from datetime import datetime
50
+ from typing import Any, Optional
51
+
52
+ from . import tools
53
+ from .agent_guards import ActionGuard, GuardPolicy
54
+ from .llm_client import LLMError
55
+ from .observation import build_observation
56
+ from .scope import ScopeFloor, describe, infer_floor
57
+
58
+ TERMINATE_MODEL_FINISHED = "model_finished"
59
+ TERMINATE_MAX_STEPS = "max_steps"
60
+ TERMINATE_IDLE = "idle_loop"
61
+ TERMINATE_DECISION_ERROR = "decision_error"
62
+ # 下限始终未覆盖、且模型反复要求结束时使用。与 max_steps 分开,才能区分
63
+ # 「步数用尽」和「该做的一直没做」。
64
+ TERMINATE_SCOPE_UNMET = "scope_unmet"
65
+ # 下限外步数预算用尽:模型仍想追加工具。此时再问一次也不可能获批,
66
+ # 直接结束,避免白花 3 次模型调用并让终止原因在 idle_loop 与 model_finished 之间抖动。
67
+ TERMINATE_BUDGET = "budget_exhausted"
68
+ # 累计 token 达到上限。与 budget_exhausted(步数预算)区分:一个是「步数」,
69
+ # 一个是「成本」,报告与统计不能混为一谈。
70
+ TERMINATE_TOKEN_BUDGET = "token_budget"
71
+
72
+ # guided:下限由问题关键词确定,达标前不得结束,达标后最多再追加 1 步;
73
+ # free:T2.2 的原始行为,范围与结束时机完全由模型判断(步数会波动)。
74
+ SCOPE_GUIDED = "guided"
75
+ SCOPE_FREE = "free"
76
+ SCOPE_MODES = (SCOPE_GUIDED, SCOPE_FREE)
77
+
78
+ DEFAULT_MAX_STEPS = 8
79
+ MAX_CONSECUTIVE_REJECTS = 3
80
+ MAX_DECISION_ERRORS = 2
81
+ MAX_SCOPE_REJECTS = 3
82
+ DEFAULT_MAX_EXTRA_STEPS = 1
83
+
84
+ AGENT_VERSION = "agent-v1"
85
+
86
+ # 本轮 Agent 只实现天气正式场景。股票场景不接入,股票专属的 check_lookahead
87
+ # 因此不在此集合内——它仍可由 pipeline 使用,但不作为天气 Agent 的可选动作。
88
+ WEATHER_AGENT_TOOLS: tuple[str, ...] = (
89
+ "compute_metrics",
90
+ "analyze_spectrum",
91
+ "run_stat_tests",
92
+ "detect_anomalies",
93
+ "detect_change_points",
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class AgentStep:
99
+ """一步轨迹。字段即 T2.5 重放所需的最小信息集。"""
100
+
101
+ index: int
102
+ action: str
103
+ args: dict[str, Any] = field(default_factory=dict)
104
+ reason: str = ""
105
+ outcome: str = ""
106
+ summary: dict[str, Any] = field(default_factory=dict)
107
+ elapsed_ms: int = 0
108
+ error: Optional[str] = None
109
+ # 该步的开始时间。engine 用它写 events,T2.5 重放时也用它对照。
110
+ started_at: str = ""
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ out = {"index": self.index, "action": self.action, "reason": self.reason,
114
+ "outcome": self.outcome, "summary": self.summary,
115
+ "elapsed_ms": self.elapsed_ms, "started_at": self.started_at}
116
+ if self.args:
117
+ out["args"] = self.args
118
+ if self.error:
119
+ out["error"] = self.error
120
+ return out
121
+
122
+
123
+ @dataclass
124
+ class AgentTrajectory:
125
+ question: str
126
+ steps: list[AgentStep] = field(default_factory=list)
127
+ terminate_reason: str = ""
128
+ results: dict[str, Any] = field(default_factory=dict) # tool -> ToolResult(含 data)
129
+ rejects: list[dict[str, Any]] = field(default_factory=list)
130
+ model: str = ""
131
+ agent_version: str = AGENT_VERSION
132
+ allowed_tools: list[str] = field(default_factory=list)
133
+ # T2.11 范围下限:guided 模式下记录下限、命中词与达标情况
134
+ scope_mode: str = SCOPE_FREE
135
+ scope: dict[str, Any] = field(default_factory=dict)
136
+ # T2.13 token 预算:limit / used / remaining / enforced / exhausted / unavailable
137
+ budget: dict[str, Any] = field(default_factory=dict)
138
+ # T2.4 越界事件与统计;与 rejects 的区别:rejects 是运行时视角,guard_events 是边界视角
139
+ guard_events: list[dict[str, Any]] = field(default_factory=list)
140
+ guard_stats: dict[str, Any] = field(default_factory=dict)
141
+
142
+ # ---- 供 T2.8 指标与报告使用的派生量 ----
143
+ @property
144
+ def tools_run(self) -> list[str]:
145
+ """成功执行过的工具(去重保序);重试成功也只算一次。"""
146
+ out: list[str] = []
147
+ for s in self.steps:
148
+ if s.outcome == "ok" and s.action not in out:
149
+ out.append(s.action)
150
+ return out
151
+
152
+ @property
153
+ def step_count(self) -> int:
154
+ return len(self.steps)
155
+
156
+ def data_of(self, tool: str, default: Any = None) -> Any:
157
+ res = self.results.get(tool)
158
+ return default if res is None else res.data
159
+
160
+ def to_dict(self) -> dict[str, Any]:
161
+ return {
162
+ "agent_version": self.agent_version,
163
+ "model": self.model,
164
+ "question": self.question,
165
+ "terminate_reason": self.terminate_reason,
166
+ "step_count": self.step_count,
167
+ "allowed_tools": self.allowed_tools,
168
+ "tools_run": self.tools_run,
169
+ "scope_mode": self.scope_mode,
170
+ "scope": self.scope,
171
+ "budget": self.budget,
172
+ "steps": [s.to_dict() for s in self.steps],
173
+ "rejects": self.rejects,
174
+ "guard_events": self.guard_events,
175
+ "guard_stats": self.guard_stats,
176
+ }
177
+
178
+
179
+ class DecisionError(RuntimeError):
180
+ """模型输出不合法(非 JSON、缺字段、动作无效)。"""
181
+
182
+
183
+ class AgentRunner:
184
+ """一次分析任务的自主编排器。
185
+
186
+ 用法::
187
+
188
+ runner = AgentRunner(ctx, client, question="分析温度趋势")
189
+ traj = runner.run()
190
+ """
191
+
192
+ def __init__(self, ctx: tools.ToolContext, client, question: str,
193
+ max_steps: int = DEFAULT_MAX_STEPS,
194
+ allowed_tools: Optional[list[str] | tuple[str, ...]] = None,
195
+ scope_mode: str = SCOPE_GUIDED,
196
+ max_extra_steps: int = DEFAULT_MAX_EXTRA_STEPS,
197
+ max_tokens: int = 0) -> None:
198
+ if client is None:
199
+ raise ValueError("AgentRunner 需要 LLM 客户端;无客户端时应走固定流水线")
200
+ if scope_mode not in SCOPE_MODES:
201
+ raise ValueError(f"scope_mode 只能是 {SCOPE_MODES},收到 {scope_mode!r}")
202
+ # 默认放开全部注册工具,便于单测直接复用;生产入口(engine)必须显式传入
203
+ # 场景白名单,否则一旦新增工具就会被模型看到。
204
+ names = list(allowed_tools) if allowed_tools is not None else tools.tool_names()
205
+ unknown = [n for n in names if n not in tools.tool_names()]
206
+ if unknown:
207
+ raise ValueError(f"白名单包含未注册的工具:{unknown}")
208
+ if not names:
209
+ raise ValueError("白名单不能为空")
210
+ self.allowed_tools = tuple(dict.fromkeys(names))
211
+ self.ctx = ctx
212
+ self.client = client
213
+ self.question = question
214
+ self.max_steps = max(1, max_steps)
215
+ self.scope_mode = scope_mode
216
+ self.max_extra_steps = max(0, int(max_extra_steps))
217
+ # 0 = 不限制;只约束模型调用,工具执行不消耗 token
218
+ self.max_tokens = max(0, int(max_tokens))
219
+ # 下限只约束「至少做什么」,顺序仍由模型决定。free 模式不设下限。
220
+ self.floor: Optional[ScopeFloor] = (
221
+ infer_floor(question, self.allowed_tools) if scope_mode == SCOPE_GUIDED else None)
222
+ self.traj = AgentTrajectory(question=question,
223
+ model=getattr(client, "model", "") or "",
224
+ allowed_tools=list(self.allowed_tools),
225
+ scope_mode=scope_mode)
226
+ self.guard = ActionGuard(GuardPolicy(
227
+ allowed_tools=self.allowed_tools,
228
+ max_steps=self.max_steps,
229
+ max_consecutive_violations=MAX_CONSECUTIVE_REJECTS,
230
+ ))
231
+ self._decision_errors = 0
232
+ self._scope_rejects = 0
233
+ # E2:预算前置检查的状态。用在报告里区分「调用后才发现超限」与
234
+ # 「调用前就拦住」,否则两者都表现为 token_budget,看不出差在哪。
235
+ self._reserve_value: Optional[int] = None
236
+ self._reserve_basis = "unavailable"
237
+ self._precheck_blocked = False
238
+
239
+ # ---------------- 对外入口 ----------------
240
+
241
+ def run(self) -> AgentTrajectory:
242
+ while True:
243
+ if len(self.traj.steps) >= self.max_steps:
244
+ self.traj.terminate_reason = TERMINATE_MAX_STEPS
245
+ break
246
+ if self.guard.consecutive_violations >= MAX_CONSECUTIVE_REJECTS:
247
+ self.traj.terminate_reason = TERMINATE_IDLE
248
+ break
249
+ if self._decision_errors >= MAX_DECISION_ERRORS:
250
+ self.traj.terminate_reason = TERMINATE_DECISION_ERROR
251
+ break
252
+ if self._scope_rejects >= MAX_SCOPE_REJECTS:
253
+ self.traj.terminate_reason = TERMINATE_SCOPE_UNMET
254
+ break
255
+ used = self._usage_total()
256
+ if self.max_tokens and used is not None:
257
+ if used >= self.max_tokens:
258
+ # 已经到限:下一次模型调用之前停,此前已选中的工具仍执行完
259
+ self.traj.terminate_reason = TERMINATE_TOKEN_BUDGET
260
+ break
261
+ reserve, basis = self._reserve_tokens(used)
262
+ self._reserve_value, self._reserve_basis = reserve, basis
263
+ if reserve is not None and used + reserve > self.max_tokens:
264
+ # 预扣:按已观测的单次调用量估计,下一步会超限就直接停,
265
+ # 而不是先花掉再报告「小幅超限」。
266
+ self._precheck_blocked = True
267
+ self.traj.terminate_reason = TERMINATE_TOKEN_BUDGET
268
+ break
269
+
270
+ try:
271
+ decision = self._decide()
272
+ except (DecisionError, LLMError) as exc:
273
+ self._decision_errors += 1
274
+ self.traj.rejects.append({"kind": "decision_error",
275
+ "detail": str(exc)[:200]})
276
+ continue
277
+
278
+ action = decision["action"]
279
+ if action == "finish":
280
+ blocked = self._finish_blocked()
281
+ if blocked:
282
+ self._reject_scope(blocked)
283
+ continue
284
+ self.traj.terminate_reason = TERMINATE_MODEL_FINISHED
285
+ break
286
+
287
+ verdict = self.guard.check(action, decision.get("args") or {},
288
+ self.traj.results, len(self.traj.steps))
289
+ if not verdict.allowed:
290
+ self.guard.record_violation(verdict, action, len(self.traj.steps))
291
+ self.traj.rejects.append({
292
+ "kind": "invalid_action", "reason_kind": verdict.kind,
293
+ "action": action, "detail": verdict.detail,
294
+ })
295
+ continue
296
+
297
+ over = self._extra_budget_exceeded(action)
298
+ if over:
299
+ # 预算用尽不是越界:工具合法、参数合法,只是不在下限内。
300
+ # 若记进 guard_events,「越界次数」会把正常的追加尝试也算进去。
301
+ self.traj.rejects.append({
302
+ "kind": "scope_exceeded", "action": action, "detail": over,
303
+ })
304
+ self.traj.terminate_reason = TERMINATE_BUDGET
305
+ break
306
+
307
+ self._execute(action, decision.get("args") or {},
308
+ decision.get("reason", ""), is_retry=verdict.is_retry)
309
+ self._scope_rejects = 0
310
+
311
+ self.traj.guard_events = list(self.guard.violations)
312
+ self.traj.guard_stats = self.guard.stats()
313
+ self.traj.scope = self._scope_report()
314
+ self.traj.budget = self._budget_report()
315
+ return self.traj
316
+
317
+ # ---------------- 范围下限 ----------------
318
+
319
+ def _extra_used(self) -> int:
320
+ """下限之外已成功执行(ok / skipped)的工具数。"""
321
+ if self.floor is None:
322
+ return 0
323
+ return sum(1 for t in self.floor_tools_run() if t not in self.floor.tools)
324
+
325
+ def floor_tools_run(self) -> list[str]:
326
+ """已成功执行的工具(去重保序),与 AgentTrajectory.tools_run 同口径。"""
327
+ return self.traj.tools_run
328
+
329
+ def _hopeless(self, tool: str) -> bool:
330
+ """下限工具已失败且重试次数用尽——再拒绝结束只会空转到 max_steps。"""
331
+ res = self.traj.results.get(tool)
332
+ if getattr(res, "outcome", None) != "failed":
333
+ return False
334
+ return self.guard.retry_count(tool) >= self.guard.policy.max_retries_per_tool
335
+
336
+ def _finish_blocked(self) -> Optional[str]:
337
+ """结束请求是否应被拒绝。返回拒绝理由,None 表示允许结束。"""
338
+ if self.floor is None:
339
+ return None
340
+ missing = self.floor.missing(self.traj.results)
341
+ if not missing:
342
+ return None
343
+ if all(self._hopeless(t) for t in missing):
344
+ return None
345
+ return ("范围下限未覆盖:{}。请先执行这些工具再结束(下限来自问题关键词:{})"
346
+ .format("、".join(missing), describe(self.floor)))
347
+
348
+ def _extra_budget_exceeded(self, action: str) -> Optional[str]:
349
+ """下限之外最多追加 max_extra_steps 步;超出即拒绝。"""
350
+ if self.floor is None or action in self.floor.tools:
351
+ return None
352
+ used = self._extra_used()
353
+ if used >= self.max_extra_steps:
354
+ return ("范围下限之外的追加步数已用尽({} / {}):{} 不在下限内,"
355
+ "请回到下限工具或结束".format(used, self.max_extra_steps, action))
356
+ return None
357
+
358
+ def _usage_total(self) -> Optional[int]:
359
+ """服务端累计 token;未返回 usage 时返回 None(不估算、不猜测)。
360
+
361
+ 客户端以 `usage_summary()` 暴露累计值;没有任何调用或服务端不给 usage
362
+ 时它返回 None。这里把「没有数据」和「用了 0」分开,前者不能用来判断预算。
363
+ """
364
+ fn = getattr(self.client, "usage_summary", None)
365
+ if not callable(fn):
366
+ return None
367
+ try:
368
+ usage = fn()
369
+ except Exception: # noqa: BLE001
370
+ return None
371
+ if not isinstance(usage, dict):
372
+ return None
373
+ try:
374
+ total = int(usage.get("total_tokens") or 0)
375
+ except (TypeError, ValueError):
376
+ return None
377
+ return total if total > 0 else None
378
+
379
+ def _reserve_tokens(self, used: int) -> tuple[Optional[int], str]:
380
+ """估计下一次模型调用的 token 数,用于预算前置检查。
381
+
382
+ 只用真实观测值:优先服务端记录的单次最大值,缺失时退回均值。
383
+ `calls == 0` 时没有依据,返回 None——第一次调用无法预扣,
384
+ 这时只能退回「调用后检查」,并在 budget 里标成 unavailable。
385
+ """
386
+ fn = getattr(self.client, "usage_summary", None)
387
+ if not callable(fn):
388
+ return None, "unavailable"
389
+ try:
390
+ usage = fn()
391
+ except Exception: # noqa: BLE001
392
+ return None, "unavailable"
393
+ if not isinstance(usage, dict):
394
+ return None, "unavailable"
395
+ try:
396
+ calls = int(usage.get("calls") or 0)
397
+ except (TypeError, ValueError):
398
+ calls = 0
399
+ if calls <= 0 or used <= 0:
400
+ return None, "unavailable"
401
+ raw_max = usage.get("max_call_tokens")
402
+ if isinstance(raw_max, (int, float)) and not isinstance(raw_max, bool) and raw_max > 0:
403
+ return int(raw_max), "observed_max"
404
+ return -(-used // calls), "observed_avg" # 向上取整
405
+
406
+ def _budget_report(self) -> dict[str, Any]:
407
+ used = self._usage_total()
408
+ limit = self.max_tokens
409
+ enforced = limit > 0
410
+ return {
411
+ "limit": limit,
412
+ "used": used,
413
+ "remaining": (max(0, limit - (used or 0)) if enforced else None),
414
+ "enforced": enforced,
415
+ "exhausted": self.traj.terminate_reason == TERMINATE_TOKEN_BUDGET,
416
+ # 设了限但服务端不给用量:预算事实上没生效,必须如实标注
417
+ "unavailable": bool(enforced and used is None),
418
+ # E2 前置检查:reserve 是「下一次调用预计消耗」,basis 说明依据,
419
+ # blocked 表示这次是调用前拦下的(used 仍小于 limit)。
420
+ "precheck": {
421
+ "enabled": bool(enforced and used is not None),
422
+ "reserve": self._reserve_value,
423
+ "basis": self._reserve_basis,
424
+ "blocked": self._precheck_blocked,
425
+ },
426
+ }
427
+
428
+ def _reject_scope(self, detail: str) -> None:
429
+ self._scope_rejects += 1
430
+ self.traj.rejects.append({"kind": "scope_incomplete", "detail": detail})
431
+
432
+ def _scope_report(self) -> dict[str, Any]:
433
+ if self.floor is None:
434
+ return {"mode": SCOPE_FREE, "enforced": False}
435
+ report = {"mode": SCOPE_GUIDED, "enforced": True}
436
+ report.update(self.floor.to_dict(self.traj.results))
437
+ report.update({
438
+ "extra_used": self._extra_used(),
439
+ "max_extra_steps": self.max_extra_steps,
440
+ "scope_rejects": self._scope_rejects,
441
+ })
442
+ return report
443
+
444
+ # ---------------- 内部实现 ----------------
445
+
446
+ def _decide(self) -> dict[str, Any]:
447
+ """调模型取一步决策。"""
448
+ payload = self.client.chat_json(self._system_prompt(), self._user_prompt())
449
+ if not isinstance(payload, dict):
450
+ raise DecisionError("决策必须是 JSON 对象")
451
+ action = payload.get("action")
452
+ if not isinstance(action, str) or not action.strip():
453
+ raise DecisionError("缺少 action 字段")
454
+ reason = payload.get("reason")
455
+ if not isinstance(reason, str) or not reason.strip():
456
+ # 理由为空说明模型没在做判断,按不合法处理
457
+ raise DecisionError("reason 不得为空")
458
+ args = payload.get("args")
459
+ if args is not None and not isinstance(args, dict):
460
+ raise DecisionError("args 必须是对象")
461
+ return {"action": action.strip(), "args": args or {}, "reason": reason.strip()}
462
+
463
+ def _execute(self, action: str, args: dict[str, Any], reason: str,
464
+ is_retry: bool = False) -> None:
465
+ started_at = datetime.now().isoformat(timespec="seconds")
466
+ res = tools.run_tool(action, self.ctx)
467
+ self.traj.results[action] = res
468
+ self.traj.steps.append(AgentStep(
469
+ index=len(self.traj.steps),
470
+ action=action, args=args, reason=reason,
471
+ outcome=res.outcome, summary=res.summary,
472
+ elapsed_ms=res.elapsed_ms, error=res.error,
473
+ started_at=started_at,
474
+ ))
475
+ self.guard.record_execution(action, res.outcome, is_retry=is_retry)
476
+
477
+ # ---------------- 提示词 ----------------
478
+
479
+ def _system_prompt(self) -> str:
480
+ catalog = "\n".join(
481
+ f"- {t['name']}:{t['description']} 适用:{t['when_to_use']}"
482
+ for t in tools.describe_tools() if t["name"] in self.allowed_tools)
483
+ return (
484
+ "你是时序分析的执行决策器。每一步从工具清单中选择一个尚未执行的分析工具,"
485
+ "或宣布结束。你会看到此前各步的执行结果摘要。\n\n"
486
+ f"可用工具:\n{catalog}\n\n"
487
+ "规则:\n"
488
+ "1. 只输出 JSON 对象,不要解释性文字或代码块标记。\n"
489
+ "2. 输出格式:{\"action\": \"工具名或 finish\", "
490
+ "\"args\": {}, \"reason\": \"一句话理由\"}。\n"
491
+ "3. action 必须是清单中的工具名,或字符串 finish;成功或跳过的工具不得重复,"
492
+ "失败的工具可以重试一次。\n"
493
+ "4. args 一律留空对象;这些工具不接受额外参数。\n"
494
+ "5. reason 必须说明为什么选它,或为什么结束;不得为空。\n"
495
+ "6. 观察里会给出「范围下限」:下限工具未全部执行时输出 finish 会被拒绝,"
496
+ "必须先补齐;下限已覆盖时应结束,不必跑完全部工具。\n"
497
+ "7. 若已有结果之间存在矛盾或某列未被覆盖而问题涉及它,可在下限之外"
498
+ "追加 1 个工具,用完即止。\n"
499
+ "8. 工具顺序自行决定;同一工具成功后不得重复,失败可重试一次。"
500
+ )
501
+
502
+ def _user_prompt(self) -> str:
503
+ """交给观察摘要器生成,不再在这里拼接无限增长的历史。"""
504
+ return build_observation(self.ctx, self.question, self.traj,
505
+ self.allowed_tools, self.max_steps,
506
+ floor=self.floor,
507
+ max_extra_steps=self.max_extra_steps)
508
+
509
+
510
+ def build_agent_runner(ctx: tools.ToolContext, question: str, env: Optional[dict[str, str]] = None,
511
+ max_steps: int = DEFAULT_MAX_STEPS,
512
+ allowed_tools: Optional[list[str] | tuple[str, ...]] = None,
513
+ scope_mode: str = SCOPE_GUIDED,
514
+ max_extra_steps: int = DEFAULT_MAX_EXTRA_STEPS,
515
+ max_tokens: int = 0) -> Optional[AgentRunner]:
516
+ """按环境变量构造运行时;无模型配置返回 None(调用方应回退固定流水线)。"""
517
+ import os
518
+
519
+ from .llm_client import client_from_env
520
+
521
+ env = env if env is not None else os.environ
522
+ client = client_from_env(env)
523
+ if client is None:
524
+ return None
525
+ return AgentRunner(ctx, client, question, max_steps=max_steps,
526
+ allowed_tools=allowed_tools, scope_mode=scope_mode,
527
+ max_extra_steps=max_extra_steps, max_tokens=max_tokens)