教程默认使用英文,可通过顶部语言链接切换中文。CLI 提示使用英文,模型回复跟随用户语言;源码展示对应当前无注释版本。
接着 mini agent-1,补上重试、恢复、流式和工具权限。
前一页停在 mini agent-1 · 第7章;这一页从 第8章:失败与重试 继续。两页对应同一份 mini_agent 代码包;原交互入口和本页的持久化运行入口分别组装。
一次正常工具循环里有两种动作:模型返回下一步意图,宿主执行这个意图。两者在代码里相邻,却不能用同一种方式重试。请求可以在约定范围内再次发送;写文件一旦发生,就已经改变了外部状态。
以“修改 report.txt 后概括结果”为例,模型先返回 write,程序写入文件并保存 tool 结果,再发下一次请求。第二次请求遇到503时,历史里已经有写入结果;再次发送这份历史,并不需要回到 write。
第一次请求 → write 意图 → 文件已写入 → 保存 tool 结果
↓
第二次模型请求
503 → 等待 → 重发
↓
最终回答
写文件这类改变外部状态的动作叫作副作用。它不是“有害行为”的同义词,只说明重新运行代码可能再次改变现场。如果操作是追加记录,重复执行就可能产生两条记录;当前进阶 write 是覆盖写入,也不能因此把重跑工具当作通用恢复方式。
401通常需要修正凭据,429可能需要等待,网络超时则只说明客户端没有取得可用响应。服务端可能已经处理了超时请求,所以“没有收到结果”不能解释成“没有执行”。
| 情况 | 当前策略 | 原因 |
|---|---|---|
| 429、502、503、504 | 次数预算内重发 | 明确允许的状态码 |
| 其他HTTP错误 | 停止并保留状态码 | 不推断重复发送能修复 |
| 超时或断连 | 结果未知,不自动重发 | 无法确认服务端是否处理 |
| 坏结构或明确截断 | 拒绝交给工具循环 | 尚未得到可用意图 |
max_retries=2 表示首发加最多两次重试,总计最多3次。退避等待从0.5秒起按指数增加,再乘0.5—1.0的随机量,避免多个客户端同时重发。Retry-After 是服务端要求的等待下限;要求30秒而本地上限10秒时,当前代码停止,不会提前发送。
10秒只是一次退避的上限。每次请求还有自己的超时,因此它不是总任务时限。Signal 能打断退避,也在响应返回后检查;阻塞的 urllib 读取仍不能保证即时取消。
47 48 49 50 51 | CONFIG.update({ "request_max_retries": int(os.environ.get("MINI_AGENT_REQUEST_MAX_RETRIES", "2")), "request_retry_base_s": 0.5, "request_retry_cap_s": 10.0, }) |
完整配置文件仍包含基础七章的其他选项。
6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | class RequestFailure(RuntimeError): def __init__(self, category, *, status=None, retry_after=None): self.category = category self.status = status self.retry_after = retry_after labels = { "http_error": "Server returned an HTTP error", "outcome_unknown": "No usable response; server outcome is unknown", "invalid_response": "Invalid response structure or JSON; no tools executed", "incomplete_response": "Response did not finish normally; no tools executed", } message = labels.get(category, category) if status is not None: message += f"(HTTP {status})" super().__init__(message) @property def retryable(self): return self.category == "http_error" and self.status in {429, 502, 503, 504} |
错误消息不携带密钥、请求正文或服务端原始错误正文。
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | def run_with_retry(operation, *, max_retries=2, base_delay=0.5, max_delay=10.0, signal=None, wait=None, random_value=None, on_retry=None): if isinstance(max_retries, bool) or not isinstance(max_retries, int) or not 0 <= max_retries <= 10: raise ValueError("max_retries must be an integer from 0 to 10") if not all(math.isfinite(x) and x >= 0 for x in (base_delay, max_delay)): raise ValueError("Retry delays must be finite and nonnegative") wait = wait or wait_for_retry random_value = random_value or random.random for attempt in range(max_retries + 1): if signal is not None: signal.check() try: result = operation() except RequestFailure as error: if not error.retryable or attempt == max_retries: raise delay = min(max_delay, base_delay * 2 ** attempt) delay *= 0.5 + 0.5 * random_value() if error.retry_after is not None: if error.retry_after > max_delay: raise delay = max(delay, error.retry_after) if on_retry is not None: on_retry(attempt + 1, error.status, delay) wait(delay, signal) else: if signal is not None: signal.check() return result |
成功分支也检查Signal,避免把停止后到达的结果继续使用。
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | def post_json(request, *, signal=None, opener=None): opener = opener or urllib.request.urlopen def once(): try: with opener(request, timeout=CONFIG["request_timeout_s"]) as response: payload = response.read() except urllib.error.HTTPError as error: try: retry_after = parse_retry_after(error.headers.get("Retry-After") if error.headers else None) failure = RequestFailure("http_error", status=error.code, retry_after=retry_after) finally: error.close() raise failure from None except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException): raise RequestFailure("outcome_unknown") from None try: return validate_response(json.loads(payload.decode("utf-8"))) except (UnicodeError, json.JSONDecodeError): raise RequestFailure("invalid_response") from None def report(number, status, delay): logger.warning("Model request retry %s/%s; HTTP %s; waiting %.2fs", number, CONFIG["request_max_retries"], status, delay) return run_with_retry( once, max_retries=CONFIG["request_max_retries"], base_delay=CONFIG["request_retry_base_s"], max_delay=CONFIG["request_retry_cap_s"], signal=signal, on_retry=report, ) |
传给重试函数的是once,不是agent_loop。
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | def validate_response(data): try: choice = data["choices"][0] message = choice["message"] if not isinstance(message, dict) or message.get("role") != "assistant": raise ValueError("assistant message required") if choice.get("finish_reason") not in {"stop", "tool_calls"}: raise RequestFailure("incomplete_response") calls = message.get("tool_calls") or [] if not isinstance(calls, list): raise ValueError("tool_calls must be a list") if (choice['finish_reason'] == 'tool_calls') != bool(calls): raise ValueError('finish_reason does not match tool calls') identifiers = set() for call in calls: if not isinstance(call["id"], str) or not call["id"] or call["id"] in identifiers: raise ValueError("tool call IDs must be nonempty and unique") identifiers.add(call["id"]) function = call["function"] if not isinstance(function["name"], str) or not function["name"]: raise ValueError("tool name required") arguments = function["arguments"] if not isinstance(arguments, str) or not isinstance(json.loads(arguments), dict): raise ValueError("tool arguments must encode an object") if message.get("content") is not None and not isinstance(message["content"], str): raise ValueError("text content must be a string or null") if not calls and message.get("content") is None: raise ValueError("response contains neither text nor tool calls") except (KeyError, IndexError, TypeError, ValueError): raise RequestFailure("invalid_response") from None return data |
这里检查工具ID和JSON对象;path与content的业务约束仍由工具处理。
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | def call_llm(messages, tools, system=SYSTEM_PROMPT, signal=None): if signal is not None: signal.check() system_text = SYSTEM_PROMPT if system is None else system full_messages = [{"role": "system", "content": system_text}] + messages payload = json.dumps( { "model": CONFIG["model"], "max_tokens": CONFIG["max_tokens"], "messages": full_messages, "tools": tools, } ).encode("utf-8") req = urllib.request.Request( CONFIG["base_url"].rstrip("/") + "/chat/completions", data=payload, method="POST", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {CONFIG['api_key']}", }, ) data = post_json(req, signal=signal) if signal is not None: signal.check() return data |
请求体构造与执行工具处于不同函数。
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | def test_full_agent_does_not_replay_tool(self): tool_reply = llm.fake_tool_response(("write", {"path": "x", "content": "y"})) script = [503, tool_reply, 429, llm.fake_text_response("done")] bodies, persisted = [], [] def opener(request, **kwargs): bodies.append(request.data) item = script.pop(0) if isinstance(item, int): raise http_error(item) return response(item) history = [{"role": "user", "content": "test"}] with patch.object(transport.urllib.request, "urlopen", side_effect=opener): with patch.object(agent.toolbox, "submit", return_value="written") as submit: agent.agent_loop(history, emit=lambda *a, **kw: None, on_append=persisted.append) self.assertEqual(submit.call_count, 1) self.assertEqual(len(history), 4) self.assertEqual(len(persisted), 3) self.assertEqual(len(bodies), 4) self.assertEqual(bodies[0], bodies[1]) self.assertEqual(bodies[2], bodies[3]) self.assertEqual(history[2]["tool_call_id"], history[1]["tool_calls"][0]["id"]) |
这是经过真实主循环的离线控制流检查。
基础 Session 回答“这段对话说过什么”,执行日志还要回答“工具走到了哪一步”。这两份信息在正常运行时一起增长,进程退出时却可能留下不同缺口。
assistant:call_A要求write started:call_A准备执行 实际工具操作 result:call_A的返回值 tool:把call_A的结果交回模型
assistant 保存完整调用意图,started 在操作之前落盘。它只证明准备执行:程序可能写完started就退出,也可能已经写完文件但还没保存result。因此started不是完成标记。
result 保存执行返回值,tool 消息用于下一次模型请求。二者分开,恢复器才能在result已保存时补消息,而不用再执行文件操作。
| 日志状态 | 已有依据 | 恢复 |
|---|---|---|
| 只有意图 | 没有进入已记录的执行阶段 | 补未执行说明,不重放 |
| started,无result | 操作可能已经发生 | 结果未知,阻止续跑 |
| result,无tool消息 | 返回值已经保存 | 复用结果补配对 |
保留未知会增加一次人工核对,但比猜测“应该没执行”更可靠。--acknowledge-unknown 表示操作者允许核对后继续;它不认证旧操作成功或失败,也不会重新执行旧调用。
Journal 每条记录经过write、flush、fsync,但它与实际文件不是同一个事务。日志损坏不能跳过:丢一条started会改变执行判断,丢一条result会抹去恢复依据。单写者锁覆盖恢复和运行,防止两个进程各补一次消息;锁文件存在不代表进程仍在运行。
69 70 71 72 73 74 | def append(self, kind, **fields): record = json.dumps({'kind': kind, **fields}, ensure_ascii=False, allow_nan=False) with self.path.open('a', encoding='utf-8', newline='\n') as out: out.write(record + '\n') out.flush() os.fsync(out.fileno()) |
fsync缩小未持久化窗口,不承诺掉电原子性。
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | def _tool(self, call, *, signal, emit): key, fn = call['id'], call['function'] inp = json.loads(fn['arguments']) signal.check() self.journal.append('started', id=key) emit('tool_start', name=fn['name'], input=inp) try: output = self.tools.submit(fn['name'], inp, signal=signal) except Exception as error: output = f'Error: {type(error).__name__}' if not isinstance(output, str): raise TypeError('Tool must return a string; outcome remains unknown') self.journal.append('result', id=key, output=output) emit('tool_end', name=fn['name'], output=output) return {'tool_call_id': key, 'content': output} |
started与result之间的退出都保留为未知。
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | def restore(self, *, acknowledge_unknown=False): events = self.read() messages, pending, starts, results, used = [], {}, set(), {}, set() for event in events[1:]: kind = event['kind'] if kind == 'message': msg = event['message'] role = msg.get('role') if role == 'assistant': if pending: raise RecoveryRequired('Previous tool results are incomplete') for call in msg.get('tool_calls') or []: key = call['id'] if key in used: raise RecoveryRequired('Duplicate tool ID within session') used.add(key) pending[key] = call elif role == 'tool': key = msg['tool_call_id'] if key not in pending: raise RecoveryRequired('Tool result has no matching intent') del pending[key] elif role != 'user' or pending: raise RecoveryRequired('Invalid message order') messages.append(msg) elif kind == 'started': key = event['id'] if key not in pending or key in starts: raise RecoveryRequired('Tool start has no matching intent or is duplicated') starts.add(key) elif kind == 'result': key = event['id'] if key not in starts or key in results: raise RecoveryRequired('Tool result has no matching start or is duplicated') results[key] = event['output'] else: raise RecoveryRequired('Duplicate journal header') unknown = [key for key in pending if key in starts and key not in results] if unknown and not acknowledge_unknown: raise RecoveryRequired('Tool outcome unknown: ' + ', '.join(unknown) + '; inspect the workspace, then acknowledge explicitly. No automatic replay.') for key in pending: output = results.get(key, 'Error: outcome unknown; inspect workspace; do not replay' if key in starts else 'Error: not executed before interruption') msg = {'role': 'tool', 'tool_call_id': key, 'content': output} self.append('message', message=msg) messages.append(msg) return messages |
恢复器没有调用handler的路径。
142 143 144 145 146 147 148 149 150 151 152 153 | def test_real_crash_unknown_blocks_then_explicit_ack(self): result = self.crash('before_result') self.assertEqual(result.returncode, 71, result.stderr) self.assertEqual((self.root / 'artifact.txt').read_text(), 'once') runtime = Runtime(self.journal, provider=lambda *a, **kw: fake_text_response('inspect')) before = self.journal.path.read_bytes() with self.assertRaises(RecoveryRequired): runtime.run() self.assertEqual(self.journal.path.read_bytes(), before) messages = runtime.run(acknowledge_unknown=True) self.assertIn('outcome unknown', messages[-2]['content']) self.assertEqual(sum(e['kind'] == 'started' for e in self.journal.read()), 1) |
不能只依据异常文字判断,现场文件与调用次数也属于判据。
非流式请求一次返回完整消息,流式请求则不断返回delta。文字与工具参数都可能被拆开:显示一个片段很容易,执行一个不完整意图却会改变文件。
arguments片段1:{"path":
arguments片段2:"report.txt","content":"hello"}
拼接结果:{"path":"report.txt","content":"hello"}
第一段既不是完整JSON,也没有完整路径。收集器先缓存参数;文字可以通过text_delta单独发给界面。只有完整响应通过校验后,才返回给agent_loop,由它保存assistant并执行工具。
SSE用空行结束一个事件,data行组成事件负载,负载里的JSON再描述delta。因此一次readline不等于一条assistant消息。多个工具的片段按index聚合,id则留给后续tool结果配对,二者用途不同。
当前收集器要求合法finish_reason与[DONE]均到达,工具索引连续、参数有效,再通过共享响应校验。length、content_filter、断流、坏参数与停止都不返回可提交消息。这里的提交只是“允许进入正式历史”,不是文件或数据库事务。
断流时保留已记录的用户输入,丢弃未完成assistant。已经展示的预览不能回头证明完整消息曾被接受;自动重试可能生成不同内容,当前流式路径因此不拼接第二次生成。
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | def call_stream(messages, tools, *, system=None, signal, on_delta=None, opener=None): body = {'model': CONFIG['model'], 'max_tokens': CONFIG['max_tokens'], 'stream': True, 'messages': [{'role': 'system', 'content': system or SYSTEM_PROMPT}] + messages} if tools: body.update(tools=tools, tool_choice='auto') request = urllib.request.Request(CONFIG['base_url'].rstrip('/') + '/chat/completions', data=json.dumps(body).encode('utf-8'), headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + CONFIG['api_key']}) try: signal.check() with (opener or urllib.request.urlopen)(request, timeout=CONFIG['request_timeout_s']) as response: if 'text/event-stream' not in response.headers.get('Content-Type', ''): raise RequestFailure('invalid_stream') lines = iter(lambda: response.readline(262145), b'') return collect_stream(lines, signal=signal, on_delta=on_delta) except urllib.error.HTTPError as error: status = error.code error.close() raise RequestFailure('http_error', status=status) from None except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException): raise RequestFailure('outcome_unknown') from None |
本机SSE用例验证这条连接,而不只是传入一个预造列表。
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | def consume(payload): nonlocal finish, done if payload == '[DONE]': done = True return data = json.loads(payload) choices = data['choices'] if not choices: return if len(choices) != 1 or choices[0].get('index') != 0 or finish is not None: raise ValueError('Expected one choice and no deltas after finish') choice = choices[0] delta = choice.get('delta', {}) if delta.get('role', 'assistant') != 'assistant': raise ValueError('Invalid role') text = delta.get('content') if text is not None: if not isinstance(text, str): raise ValueError('Text delta must be a string') content.append(text) if on_delta: on_delta(text) for fragment in delta.get('tool_calls') or []: index = fragment['index'] if type(index) is not int or not 0 <= index < 32: raise ValueError('Tool index out of range') call = calls.setdefault(index, {'id': '', 'type': 'function', 'function': {'name': '', 'arguments': ''}}) if fragment.get('type', 'function') != 'function': raise ValueError('Unsupported tool type') for field in ('id',): value = fragment.get(field, '') if not isinstance(value, str): raise ValueError('Invalid ID delta') call[field] += value for field in ('name', 'arguments'): value = fragment.get('function', {}).get(field, '') if not isinstance(value, str): raise ValueError('Invalid argument delta') call['function'][field] += value reason = choice.get('finish_reason') if reason is not None: if reason not in {'stop', 'tool_calls'}: raise RequestFailure('incomplete_response') finish = reason |
文本增量只送往on_delta,不是执行入口。
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | def collect_stream(lines, *, signal, on_delta=None): content, calls, finish, done, size = [], {}, None, False, 0 event = [] def consume(payload): nonlocal finish, done if payload == '[DONE]': done = True return data = json.loads(payload) choices = data['choices'] if not choices: return if len(choices) != 1 or choices[0].get('index') != 0 or finish is not None: raise ValueError('Expected one choice and no deltas after finish') choice = choices[0] delta = choice.get('delta', {}) if delta.get('role', 'assistant') != 'assistant': raise ValueError('Invalid role') text = delta.get('content') if text is not None: if not isinstance(text, str): raise ValueError('Text delta must be a string') content.append(text) if on_delta: on_delta(text) for fragment in delta.get('tool_calls') or []: index = fragment['index'] if type(index) is not int or not 0 <= index < 32: raise ValueError('Tool index out of range') call = calls.setdefault(index, {'id': '', 'type': 'function', 'function': {'name': '', 'arguments': ''}}) if fragment.get('type', 'function') != 'function': raise ValueError('Unsupported tool type') for field in ('id',): value = fragment.get(field, '') if not isinstance(value, str): raise ValueError('Invalid ID delta') call[field] += value for field in ('name', 'arguments'): value = fragment.get('function', {}).get(field, '') if not isinstance(value, str): raise ValueError('Invalid argument delta') call['function'][field] += value reason = choice.get('finish_reason') if reason is not None: if reason not in {'stop', 'tool_calls'}: raise RequestFailure('incomplete_response') finish = reason try: for raw in lines: signal.check() size += len(raw) if size > 2_000_000 or len(raw) > 262144: raise ValueError('Stream exceeds size limit') line = raw.decode('utf-8').rstrip('\r\n') if not line: if event: consume('\n'.join(event)) event = [] if done: break elif line.startswith('data:'): event.append(line[5:].lstrip(' ')) signal.check() if not done or finish is None or sorted(calls) != list(range(len(calls))): raise RequestFailure('incomplete_stream') if (finish == 'tool_calls') != bool(calls): raise ValueError('finish_reason does not match tool calls') message = {'role': 'assistant', 'content': ''.join(content) if content else None} if calls: message['tool_calls'] = [calls[i] for i in sorted(calls)] return validate_response({'choices': [{'message': message, 'finish_reason': finish}]}) except (ValueError, KeyError, TypeError, AttributeError, UnicodeError): raise RequestFailure('invalid_stream') from None |
缺少DONE或finish,均不返回半截响应。
23 24 25 26 27 28 29 30 31 32 33 34 35 36 | def _request(self, messages, tools, *, system, signal): if self.provider: response = self.provider(messages, tools, system=system, signal=signal) elif self.stream: response = call_stream(messages, tools, system=system, signal=signal, on_delta=lambda text: self.emit('text_delta', text=text)) else: response = llm.call_llm(messages, tools, system=system, signal=signal) signal.check() response = validate_response(response) used = {c['id'] for m in messages for c in m.get('tool_calls') or []} if any(c['id'] in used for c in llm.extract_tool_calls(response)): raise RecoveryRequired('Provider reused a tool ID; execution refused') return response |
不同请求来源经过同一响应检查。
工具有两份表示:声明发给模型,handler留在宿主。声明帮助模型选择动作,授权则决定真实动作能否发生。默认只读需要同时影响这两个位置。
请求只提供read
Provider仍返回write("report.txt", "hello")
submit检查allow_write=False → PermissionError
report.txt不应产生
执行入口检查名称、授权和参数对象,handler再检查具体业务字段。一个结构完整的JSON响应仍可能包含越界路径或错误content类型。
两个会话如果共享全局cwd,一个切目录可能改变另一个相对路径的含义。WorkspaceTools保存自己的绝对root,每个路径从该root解析,不使用os.chdir。写权限来自本次--allow-write,不从旧日志继承。
字符串前缀无法区分project与project-other,也无法识别指向外部的符号链接或junction。path先resolve,再检查祖先关系;日志目录、Git元数据、设备名和备用数据流有额外限制。
Signal在检查点阻止后续动作。写入开始之前的检查能减少停止后继续操作的窗口,但不能撤销已经开始的修改。进程在操作中退出时,Journal仍可能只能保留未知。
8 9 10 11 12 13 14 15 16 17 18 | def __init__(self, root, *, allow_write=False): self.root = Path(root).resolve(strict=True) if not self.root.is_dir(): raise ValueError('Workspace root must be a directory') self.allow_write = allow_write self.reserved = set() self.registry = {} for schema in TOOLS: name = schema['function']['name'] if name in {'read', 'write', 'edit'}: self.register(name, schema, getattr(self, '_' + name), mutating=name != 'read') |
默认只读并不删除写handler;入口仍有授权检查。
45 46 47 48 49 50 51 52 53 54 | def submit(self, name, inp, *, signal): signal.check() if name not in self.registry: raise PermissionError('Tool is not registered') _, handler, write = self.registry[name] if write and not self.allow_write: raise PermissionError('Write access requires --allow-write') if not isinstance(inp, dict): raise ValueError('Tool arguments must be an object') return handler(inp, signal=signal) |
拒绝应发生在实际文件操作之前。
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | def path(self, value): if not isinstance(value, str) or not value: raise ValueError('path must be a nonempty string') path = (self.root / value).resolve() if not path.is_relative_to(self.root): raise PermissionError('Path is outside the workspace') relative = path.relative_to(self.root) if any(':' in part or part.endswith((' ', '.')) or part.split('.')[0].upper() in {'CON', 'PRN', 'AUX', 'NUL', *(f'COM{i}' for i in range(1, 10)), *(f'LPT{i}' for i in range(1, 10))} for part in relative.parts): raise PermissionError('Device paths, alternate data streams, and ambiguous names are not supported') if (relative.parts and relative.parts[0].lower() in {'.mini_agent', '.git'}) or path in self.reserved: raise PermissionError('Model tools cannot access journals or Git metadata') return path |
这不防御任意恶意并发进程的链接替换或可信Python代码。
77 78 79 80 81 82 83 84 | def _write(self, inp, *, signal): path, content = self.path(inp.get('path')), inp.get('content') if not isinstance(content, str): raise ValueError('content must be a string') signal.check() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding='utf-8') return f'Wrote {len(content)} characters' |
授权、参数和停止分别承担不同判断。
运行机制和任务效果是两个层次。故障回归检查给定输入下程序怎样处理;任务评测还涉及模型是否理解要求、选择有效动作,并产出符合标准的结果。
| 任务 | 脚本动作 | 判据 |
|---|---|---|
| read | 读取第二行 | 结果精确为2: beta\n |
| write_edit | 先写再编辑 | 文件内容为hello world |
| deny_write | 只读时仍提出write | 无文件且返回PermissionError |
三个任务经过真实Runtime、Journal和文件工具,只有生成意图的Provider被脚本替换。这样保留工程连接,把模型随机性移出检查范围。
每个attempt创建临时目录、输入、日志、Signal和请求计数器。否则上一轮的正确文件可能让下一轮没有执行也被判通过。三任务各重复三次得到9个attempt,重复检查的是状态与控制流,不是模型生成方差。
请求数来自Provider调用,工具数来自tool_start,所以被拒绝的write也可能计为一次工具尝试。耗时记录运行代价,未采集的token与费用保留null。没有测量不等于零成本。
最终判据独立读取产物。9/9脚本通过只能说明9次预设动作完成了约定控制流,不能报告为“模型成功率100%”。真实比较还需要固定模型、任务版本、预算和多次运行。
24 25 26 27 28 29 30 31 32 33 34 | def provider(messages, tools, **kwargs): nonlocal requests requests += 1 if requests == 1: if task == 'read': return fake_tool_response(('read', {'path': 'input.txt', 'offset': 2, 'limit': 1})) return fake_tool_response(('write', {'path': 'answer.txt', 'content': 'hello'})) if task == 'write_edit' and requests == 2: return fake_tool_response(('edit', {'path': 'answer.txt', 'old_string': 'hello', 'new_string': 'hello world'})) return fake_text_response(messages[-1]['content']) |
这个函数不是能力模型,动作来自预设分支。
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | def evaluate(repeats=3): if type(repeats) is not int or repeats < 1: raise ValueError('repeats must be a positive integer') rows = [] for task in ('read', 'write_edit', 'deny_write'): for repeat in range(1, repeats + 1): with tempfile.TemporaryDirectory(prefix='mini-agent-eval-') as tmp: root = Path(tmp) (root / 'input.txt').write_text('alpha\nbeta\n', encoding='utf-8') journal = Journal.create(root / '.mini_agent' / 'run.jsonl', root) requests, tool_count = 0, 0 def provider(messages, tools, **kwargs): nonlocal requests requests += 1 if requests == 1: if task == 'read': return fake_tool_response(('read', {'path': 'input.txt', 'offset': 2, 'limit': 1})) return fake_tool_response(('write', {'path': 'answer.txt', 'content': 'hello'})) if task == 'write_edit' and requests == 2: return fake_tool_response(('edit', {'path': 'answer.txt', 'old_string': 'hello', 'new_string': 'hello world'})) return fake_text_response(messages[-1]['content']) def emit(kind, **kw): nonlocal tool_count if kind == 'tool_start': tool_count += 1 start = time.perf_counter() messages, error_kind = [], None try: messages = Runtime(journal, provider=provider, allow_write=task == 'write_edit', emit=emit).run('Execute the task contract', max_turns=4) except Exception as error: error_kind = type(error).__name__ elapsed = time.perf_counter() - start answer = root / 'answer.txt' passed = not error_kind and bool(messages) and ((task == 'read' and messages[-1]['content'] == '2: beta\n') or (task == 'write_edit' and answer.is_file() and answer.read_text(encoding='utf-8') == 'hello world') or (task == 'deny_write' and not answer.exists() and 'PermissionError' in messages[-1]['content'])) rows.append({'task': task, 'repeat': repeat, 'passed': passed, 'error': error_kind, 'requests': requests, 'tools': tool_count, 'elapsed_s': elapsed, 'tokens': None, 'cost': None}) return {'mode': 'scripted-control-flow', 'real_model': False, 'attempts': rows, 'summary': {'passed': sum(r['passed'] for r in rows), 'total': len(rows), 'pass_rate': sum(r['passed'] for r in rows) / len(rows), 'mean_elapsed_s': statistics.mean(r['elapsed_s'] for r in rows)}, 'limitations': ['Scripted providers do not measure model accuracy', 'Real tokens and costs were not measured', 'This small task set is not a general coding benchmark']} |
passed条件与Provider宣称成功的文字分开。
65 66 67 68 69 70 71 72 73 74 75 76 | def main(): parser = argparse.ArgumentParser(description='Run a deterministic offline control-flow evaluation') parser.add_argument('--repeats', type=int, default=3) parser.add_argument('--output', type=Path) args = parser.parse_args() report = evaluate(args.repeats) value = json.dumps(report, ensure_ascii=False, indent=2) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(value + '\n', encoding='utf-8') print(value) return 0 if report['summary']['passed'] == report['summary']['total'] else 1 |
保存输出时使用新的路径,避免覆盖已有实验记录。
281 282 283 284 285 286 287 288 | def test_evaluation_records_runtime_failures(self): from .evaluation import evaluate with patch('mini_agent.evaluation.Runtime.run', side_effect=RuntimeError('private-detail')): report = evaluate(1) self.assertEqual(report['summary']['passed'], 0) self.assertEqual(report['summary']['total'], 3) self.assertTrue(all(r['error'] == 'RuntimeError' for r in report['attempts'])) self.assertNotIn('private-detail', json.dumps(report)) |
测量装置也需要自己的失败判据。
agent_loop仍负责请求、保存assistant、执行工具与配对结果。Runtime不另造一套循环,而是提供这些位置需要调用的对象,让它们属于同一次运行。
| 接口 | 循环何时调用 | Runtime职责 |
|---|---|---|
| request_fn | 获取完整响应 | Provider或传输路径及校验 |
| on_append | 追加消息 | 保存Journal事件 |
| tool_runner | 执行工具意图 | started、授权、工具、result |
基础调用方不传新增接口时仍走默认路径。进阶入口显式接入自己的对象,不因为增加SSE或执行日志而复制主循环。
Runtime A → Journal A / root A / Signal A / registry A Runtime B → Journal B / root B / Signal B / registry B
A停止或注册工具不改变B。若两个运行器指向同一日志,则由单写者锁阻止并发使用。这是实例状态分离,不包含多agent的任务路由与共享文件冲突处理。
当前入口没有基础Session迁移、shell、并发队列、自动压缩或动态技能加载。固定system文本与完整历史是当前选择;尤其自动摘要涉及执行证据与消息改写的协调,不能只把旧钩子打开就算完成。
12 13 14 15 16 17 18 19 20 21 | def __init__(self, journal, *, provider=None, allow_write=False, stream=False, emit=None): self.journal = journal header = journal.read()[0] self.tools = WorkspaceTools(header['root'], allow_write=allow_write) self.tools.reserved.update({journal.path, journal.path.with_name(journal.path.name + '.lock')}) self.signal = Signal() self.provider = provider self.stream = stream self.emit = emit or (lambda kind, **kw: None) |
Journal记录位置也加入工具保留路径。
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | def run(self, text=None, *, max_turns=8, acknowledge_unknown=False): if type(max_turns) is not int or max_turns < 1: raise ValueError('max_turns must be a positive integer') with self.journal.locked(): self.signal.check() messages = self.journal.restore(acknowledge_unknown=acknowledge_unknown) if text is not None: if not isinstance(text, str) or not text.strip(): raise ValueError('Task must not be empty') msg = {'role': 'user', 'content': text} self.journal.append('message', message=msg) messages.append(msg) if not messages: raise ValueError('A new session requires a task') if text is None and messages[-1]['role'] == 'assistant' and not messages[-1].get('tool_calls'): return messages return agent.agent_loop(messages, tools=self.tools.definitions(), signal=self.signal, system='You are a coding agent. Use only the provided tools. ' 'Tool outcomes marked unknown require human inspection; never replay them.', max_turns=max_turns, emit=self.emit, request_fn=self._request, tool_runner=self._tool, on_append=lambda msg: self.journal.append('message', message=msg)) |
锁覆盖整个恢复与运行,不只覆盖打开日志的一瞬间。
20 21 22 23 | def register(self, name, schema, handler, *, mutating=True): if name in self.registry or schema['function']['name'] != name or not callable(handler): raise ValueError('Duplicate tool or invalid registration') self.registry[name] = (deepcopy(schema), handler, mutating) |
可信Python仍拥有宿主权限,注册表不是沙箱。
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | def main(argv=None): setup_console() parser = argparse.ArgumentParser(description='mini agent-2: read-only by default, no shell tool') parser.add_argument('prompt', nargs='?') parser.add_argument('--root', default='.', help='Root for new sessions; resume uses the journal root') parser.add_argument('--resume', type=Path, help='Resume a journal (not a v1 Session JSONL file)') parser.add_argument('--status', action='store_true', help='Print execution stages without model requests') parser.add_argument('--stream', action='store_true') parser.add_argument('--allow-write', action='store_true', help='Allow write/edit for this run') parser.add_argument('--acknowledge-unknown', action='store_true', help='Acknowledge inspected unknown outcomes and continue') parser.add_argument('--max-turns', type=int, default=8) args = parser.parse_args(argv) if args.status: if not args.resume: parser.error('--status requires --resume') journal = Journal(args.resume) with journal.locked(): for event in journal.read(): if event['kind'] in {'started', 'result'}: print(event['kind'], event['id']) return 0 if args.resume is None and not args.prompt: parser.error('A new session requires task text') if args.max_turns < 1: parser.error('--max-turns must be positive') require_api_key() journal = Journal(args.resume) if args.resume else Journal.create( Path(args.root) / '.mini_agent' / 'advanced' / (uuid.uuid4().hex + '.jsonl'), args.root) print('Journal: ', journal.path) print('Streamed text is a preview; only complete validated responses enter history.' if args.stream else 'Non-streaming mode') finished = False def render(kind, **kw): nonlocal finished if kind == 'text_delta': print(kw['text'], end='', flush=True) elif kind == 'text' and not args.stream: print(kw['text']) elif kind == 'done': finished = True elif kind in {'aborted', 'max_turns'}: print('\nRun stopped: ', kind) runtime = Runtime(journal, allow_write=args.allow_write, stream=args.stream, emit=render) try: messages = runtime.run(args.prompt, max_turns=args.max_turns, acknowledge_unknown=args.acknowledge_unknown) if messages[-1]['role'] == 'assistant' and not messages[-1].get('tool_calls'): finished = True return 0 if finished else 2 except KeyboardInterrupt: runtime.signal.abort('Ctrl+C') print('\nStopped; inspect the journal before resuming. Preview text is not committed.') return 130 except (RecoveryRequired, RuntimeError, OSError, ValueError) as error: print('\nRun incomplete: ', str(error) if isinstance(error, (RecoveryRequired, RequestFailure)) else type(error).__name__) return 1 |
正常完成0、未完成2、运行错误1、中断130;退出0不代替产物验收。