mini agent-2 · 手搓一个 coding agent
English

教程默认使用英文,可通过顶部语言链接切换中文。CLI 提示使用英文,模型回复跟随用户语言;源码展示对应当前无注释版本。

mini agent-2

接着 mini agent-1,补上重试、恢复、流式和工具权限。

目录
第 8 章 · 失败与重试 只重发请求,不重做已经发生的操作
第 9 章 · 执行记录与恢复 把“没有结果”拆成三种状态
第 10 章 · 流式响应 文字可以先显示,工具必须等完整
第 11 章 · 工具权限与停止 模型选择动作,宿主决定能否执行
第 12 章 · 测试与效果评估 把程序走通与模型做对分开
第 13 章 · 会话组合与扩展接口 把生命周期接回同一条循环
mini agent-2 · 8—13章

前一页停在 mini agent-1 · 第7章;这一页从 第8章:失败与重试 继续。两页对应同一份 mini_agent 代码包;原交互入口和本页的持久化运行入口分别组装。

第 8 章

失败与重试

只重发请求,不重做已经发生的操作
目标 给一次模型请求增加有限重试,同时保留已经完成的工具操作;不把整个 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 读取仍不能保证即时取消。

设计取舍

本章新增或修改

修改mini_agent/config.py集中保存次数与等待预算
新增mini_agent/retry.py错误类型、退避与停止
新增mini_agent/transport.pyHTTP分类与响应校验
修改mini_agent/llm.py将发送段交给传输层
新增mini_agent/retry_selfcheck.py请求与副作用范围的回归

怎么实现

第 1 步:配置只约束请求层
  • CONFIG 保留地址、模型与密钥等原字段,只增加重试预算。次数为0表示关闭自动重试,不会关闭正常请求。
重试预算mini_agent/config.py:47–51点击展开关键代码
mini_agent/config.py第 47–51 行
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,
})

完整配置文件仍包含基础七章的其他选项。

第 2 步:把错误变成可以判断的数据
  • RequestFailure 保存 category、status 和 retry_after。只有明确的HTTP错误白名单返回 retryable=True,未知结果不会进入重发分支。
结构化请求错误mini_agent/retry.py:6–24点击展开关键代码
mini_agent/retry.py第 6–24 行
 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}

错误消息不携带密钥、请求正文或服务端原始错误正文。

第 3 步:用一个小循环管理一次操作
  • operation 代表一次HTTP尝试。run_with_retry 负责次数、等待和停止;它不知道工具名称,也不会执行文件操作。
  • wait 和随机数可以注入,回归不需要真实等待。异常不是 RequestFailure 时继续向外传播。
有限重试与退避mini_agent/retry.py:38–67点击展开关键代码
mini_agent/retry.py第 38–67 行
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,避免把停止后到达的结果继续使用。

第 4 步:在HTTP边界分类并检查响应
  • HTTPError 在它的父类 URLError 之前处理,以保留状态码。once 读取正文后调用 validate_response,不把HTTP 200直接解释成可执行意图。finish_reason 只接受 stop 或 tool_calls,并要求与是否存在工具调用一致。
一次请求的范围mini_agent/transport.py:67–96点击展开关键代码
mini_agent/transport.py第 67–96 行
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。

完整响应检查mini_agent/transport.py:34–64点击展开关键代码
mini_agent/transport.py第 34–64 行
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的业务约束仍由工具处理。

第 5 步:接回原调用方并核对次数
  • call_llm 保留原请求体,只替换发送段。同一个Request可以重发,主循环与已写入的工具结果不重建。
  • 回归把503、工具意图、429、最终回答依次返回,分别统计HTTP发送、工具执行和历史条数。
请求层的接线mini_agent/llm.py:14–45点击展开关键代码
mini_agent/llm.py第 14–45 行
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

请求体构造与执行工具处于不同函数。

四次请求,一次工具mini_agent/retry_selfcheck.py:174–194点击展开关键代码
mini_agent/retry_selfcheck.py第 174–194 行
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"])

这是经过真实主循环的离线控制流检查。

第 9 章

执行记录与恢复

把“没有结果”拆成三种状态
目标 在聊天历史之外记录工具阶段,恢复时区分未开始、结果未知与结果已保存,不盲目重做旧调用。

要解决的问题

原理

基础 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会抹去恢复依据。单写者锁覆盖恢复和运行,防止两个进程各补一次消息;锁文件存在不代表进程仍在运行。

设计取舍

本章新增或修改

新增mini_agent/journal.py事件、严格读取、文件锁与恢复
新增mini_agent/runtime.py把执行阶段接到真实工具调用

怎么实现

第 1 步:让证据先于下一步操作落盘
  • append 只负责保存一条带kind的事件。调用方在它成功返回之后才继续执行,落盘错误不会被静默忽略。
追加一条执行事件mini_agent/journal.py:69–74点击展开关键代码
mini_agent/journal.py第 69–74 行
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缩小未持久化窗口,不承诺掉电原子性。

第 2 步:在工具前后连接started与result
  • Runtime._tool 先保存started,再调用submit,拿到字符串结果后保存result。try只包工具本身,日志与宿主事件错误留在外层。
实际执行与记录顺序mini_agent/runtime.py:38–53点击展开关键代码
mini_agent/runtime.py第 38–53 行
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之间的退出都保留为未知。

第 3 步:按ID恢复尚未配齐的调用
  • restore 以pending、starts和results追踪同一ID。已存result可以补消息;未知先抛RecoveryRequired,发生在模型请求之前。
恢复配对,不执行旧工具mini_agent/journal.py:76–123点击展开关键代码
mini_agent/journal.py第 76–123 行
 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的路径。

第 4 步:在真实退出点检查文件与历史
  • 子进程先写文件,在result之前直接退出。新运行器恢复时应阻止Provider调用;另一个退出点放在result之后,验证复用结果。
写后退出的恢复回归mini_agent/advanced_selfcheck.py:142–153点击展开关键代码
mini_agent/advanced_selfcheck.py第 142–153 行
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)

不能只依据异常文字判断,现场文件与调用次数也属于判据。

第 10 章

流式响应

文字可以先显示,工具必须等完整
目标 把流式片段收集成原循环可以使用的完整响应;预览文字不触发工具,也不等同于历史提交。

要解决的问题

原理

非流式请求一次返回完整消息,流式请求则不断返回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。已经展示的预览不能回头证明完整消息曾被接受;自动重试可能生成不同内容,当前流式路径因此不拼接第二次生成。

设计取舍

本章新增或修改

新增mini_agent/streaming.py真实HTTP连接、SSE聚合与完整消息校验

怎么实现

第 1 步:把HTTP连接交给流收集器
  • call_stream设置stream=true,检查Content-Type,以有界readline把网络行交给collect_stream。此路径不自动重试。
真实流式请求mini_agent/streaming.py:90–111点击展开关键代码
mini_agent/streaming.py第 90–111 行
 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用例验证这条连接,而不只是传入一个预造列表。

第 2 步:按调用索引拼接字段
  • consume把同一index的id、name和arguments分别累积。参数保持字符串,最终才解析为对象;中间没有submit调用。
消费一个SSE事件mini_agent/streaming.py:16–60点击展开关键代码
mini_agent/streaming.py第 16–60 行
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,不是执行入口。

第 3 步:在完整结束之后返回响应
  • collect_stream按空行分帧,限制总字节数和单行长度;终止后检查finish、DONE与调用索引,再形成完整message。
事件边界与提交门槛mini_agent/streaming.py:12–87点击展开关键代码
mini_agent/streaming.py第 12–87 行
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,均不返回半截响应。

第 4 步:复用原循环的执行与落盘顺序
  • Runtime._request只把text_delta送往显示。完整结果返回后仍检查Signal与响应结构,后续保存和执行继续沿原循环进行。
预览与正式响应的接线mini_agent/runtime.py:23–36点击展开关键代码
mini_agent/runtime.py第 23–36 行
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

不同请求来源经过同一响应检查。

第 11 章

工具权限与停止

模型选择动作,宿主决定能否执行
目标 让根目录、工具注册与本次写授权属于运行器实例,在执行入口检查权限,而不依赖模型自律。

要解决的问题

原理

工具有两份表示:声明发给模型,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仍可能只能保留未知。

设计取舍

本章新增或修改

新增mini_agent/permissions.py实例根目录、可信注册、授权与文件工具

怎么实现

第 1 步:给每个会话建立自己的工具状态
  • __init__保存root、allow_write与registry,只注册read、write、edit。模型不能通过响应导入或注册Python函数。
实例级工具集mini_agent/permissions.py:8–18点击展开关键代码
mini_agent/permissions.py第 8–18 行
 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;入口仍有授权检查。

第 2 步:在handler之前检查授权
  • submit先检查停止和注册表,再判断写权限。即使声明没有暴露write,这里仍会拦截直接传入的调用。
调度入口的权限判断mini_agent/permissions.py:45–54点击展开关键代码
mini_agent/permissions.py第 45–54 行
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)

拒绝应发生在实际文件操作之前。

第 3 步:解析路径并保护执行证据
  • path不相信字符串前缀。resolve后的越界路径被拒绝,模型也不能通过工具修改自己的日志与Git元数据。
路径边界mini_agent/permissions.py:29–43点击展开关键代码
mini_agent/permissions.py第 29–43 行
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代码。

第 4 步:在实际修改前检查停止
  • _write先检查路径和content,再检查Signal,最后创建父目录和写入。检查点不是文件事务,不能撤回已开始的写入。
业务字段与修改检查点mini_agent/permissions.py:77–84点击展开关键代码
mini_agent/permissions.py第 77–84 行
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'

授权、参数和停止分别承担不同判断。

第 12 章

测试与效果评估

把程序走通与模型做对分开
目标 预先定义任务产物判据,让真实Runtime接受离线脚本驱动,分别观察控制流、结束状态和文件结果。

要解决的问题

原理

运行机制和任务效果是两个层次。故障回归检查给定输入下程序怎样处理;任务评测还涉及模型是否理解要求、选择有效动作,并产出符合标准的结果。

先固定能直接检查的任务契约

任务脚本动作判据
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%”。真实比较还需要固定模型、任务版本、预算和多次运行。

设计取舍

本章新增或修改

新增mini_agent/evaluation.py三任务、独立尝试与JSON报告
新增mini_agent/advanced_selfcheck.py故障与权限集成回归

怎么实现

第 1 步:固定脚本意图而保留真实执行链
  • provider按请求次数返回read、write或edit;deny_write故意发起越权调用,检查程序入口而不是模型自律。
确定性Providermini_agent/evaluation.py:24–34点击展开关键代码
mini_agent/evaluation.py第 24–34 行
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'])

这个函数不是能力模型,动作来自预设分支。

第 2 步:隔离尝试并检查实际产物
  • evaluate为每次尝试创建临时目录和Journal。运行结束后直接检查文件、错误与最终内容,临时数据不影响下一轮。
一次任务到一组尝试mini_agent/evaluation.py:13–62点击展开关键代码
mini_agent/evaluation.py第 13–62 行
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宣称成功的文字分开。

第 3 步:把判定保存为可检查的报告
  • main接受重复次数与输出路径。只要存在失败,进程返回非零;JSON文件存在不表示任务全部通过。
报告与退出状态mini_agent/evaluation.py:65–76点击展开关键代码
mini_agent/evaluation.py第 65–76 行
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

保存输出时使用新的路径,避免覆盖已有实验记录。

第 4 步:检查评估器能否识别运行错误
  • 用例让Runtime.run确定地失败,确认报告给出失败而不是丢掉样本或误计通过,同时不泄露原始异常内容。
评估错误的回归mini_agent/advanced_selfcheck.py:281–288点击展开关键代码
mini_agent/advanced_selfcheck.py第 281–288 行
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))

测量装置也需要自己的失败判据。

第 13 章

会话组合与扩展接口

把生命周期接回同一条循环
目标 通过明确接口组合Provider、Journal、工具与Signal,让每个会话有自己的状态,并保留主循环的轮次和消息配对职责。

要解决的问题

原理

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文本与完整历史是当前选择;尤其自动摘要涉及执行证据与消息改写的协调,不能只把旧钩子打开就算完成。

设计取舍

本章新增或修改

修改mini_agent/agent.py可选request_fn与tool_runner接口
新增mini_agent/advanced.py参数、入口状态与退出码

怎么实现

第 1 步:构造同一次运行的对象
  • Runtime从Journal头部取得root,创建自己的工具和Signal。权限来自本次allow_write,不从日志继承。
运行器持有的状态mini_agent/runtime.py:12–21点击展开关键代码
mini_agent/runtime.py第 12–21 行
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记录位置也加入工具保留路径。

第 2 步:把恢复与循环放在同一生命周期
  • run先持锁恢复,再记录新输入并进入循环。正常完成的日志在没有新任务时直接返回,不再次请求模型。
恢复、追加与主循环的组装mini_agent/runtime.py:55–76点击展开关键代码
mini_agent/runtime.py第 55–76 行
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))

锁覆盖整个恢复与运行,不只覆盖打开日志的一瞬间。

第 3 步:注册可信工具时明确契约
  • 名称、Schema与handler必须对应。mutating默认True,表示按修改类动作处理;这由宿主标注,不是自动分析函数副作用。
可信扩展入口mini_agent/permissions.py:20–23点击展开关键代码
mini_agent/permissions.py第 20–23 行
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仍拥有宿主权限,注册表不是沙箱。

第 4 步:从命令行进入同一组装路径
  • --status读取已有阶段后返回,不请求模型。实际运行创建Runtime并解释结束状态;root来自恢复日志,写权限来自本次启动。
进阶入口与退出码mini_agent/advanced.py:11–66点击展开关键代码
mini_agent/advanced.py第 11–66 行
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不代替产物验收。