Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ __pycache__/
# C extensions
*.so

# Claude AI
.claude/

# Distribution / packaging
.Python
build/
Expand Down
59 changes: 31 additions & 28 deletions learn-pi-agent/s01_minimal_agent_core/README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
# s01: Agent Core — 先存住一轮对话

> *一轮,先存下来。*
> **Pi 边界**:provider 输入边界 —— core 的内部状态和 provider 调用之间的第一道分隔
> *把一轮对话,先存下来。*
> **Pi Agent 核心的边界**: provider 输入边界 —— core 的内部状态和 provider 调用之间的第一道隔断

@dethan3 dethan3 Jun 24, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“隔断”这个词用得可能不是太精准,我再想想


`s01` → [下一节:s02](../s02_tool_contract/)

---

## 问题

你让模型回答一句话,模型给了回复,然后就停了。
你让模型回答一个问题,模型给了回复,然后就停了。

如果只是一次问答,这没问题。但你想让它"接着刚才的话继续",就会遇到第一个麻烦:**每一次调用都是独立的,模型自己并不记得上一句说了什么。**
如果只是一轮问答,这没问题。但你想让它"接着刚才的话继续",就会遇到第一个麻烦:**每一次调用都是独立的,模型自己并不记得上一句说了什么。**

所以 core 要做的第一件事,不是让模型变得更聪明,而是**先把这一轮对话记下来**——用户说了什么、模型回了什么,按顺序存在 core 里。有了这份记录,模型才接得上"刚才",下一轮才有继续的基础。
所以 core 要做的第一件事,不是让模型变得更聪明,而是**先把这一轮对话记下来**——用户说了什么、模型回了什么,按顺序存在 core 里。有了这份记录,模型才接得上"刚才的对话",下一轮才有继续的基础。

s01 只做这一件事:存下一轮对话
s01 从存一轮对话开始,同时建立起 core 和 provider 的界线

---

Expand All @@ -24,29 +24,32 @@ s01 只做这一件事:存下一轮对话。
一轮对话进入 core,中间要经过一条边界:

```text
AgentState(core 内部) ──ProviderInput──> Provider
AgentState(core 内部) ── ProviderInput ──> Provider
```

core 内部怎么存,是 core 自己的事;provider 能看到什么,由 ProviderInput 决定。这两边**故意不同**:provider 拿不到 core 的内部状态,只拿到一份为它准备好的输入
core 内部怎么存,是 core 自己的事;provider 能看到什么,由 ProviderInput 决定。这两边**故意不同**:provider 拿不到 core 的内部状态,只能拿到一份为它准备好的输入信息

用 `runOneTurn` 一个函数串起这一轮
用 `runOneTurn` 函数串起这轮流程

| 步骤 | 动作 |
| --- | --- |
| 1 | 用户消息进 AgentState |
| 1 | 用户消息进入 AgentState |
| 2 | 从 AgentState 构造 ProviderInput |
| 3 | 调 Provider,拿到 AssistantMessage |
| 4 | assistant 消息存回 AgentState |
| 3 | 调 Provider,拿到 assistant 消息 |
| 4 | assistant 消息存回 AgentState |

另外有两处设计先提一下,后面会用到:assistant 消息带一个**停止原因**(这一轮是正常结束,还是出了错);core 的输出统一走一层 **Output**,不直接打印。
另外有两处设计先提一下,后面会用到:

1. assistant 消息带一个**停止原因**(这一轮是正常结束,还是出了错);
2. core 的输出统一走一层 **Output**,不直接打印。

---

## 工作原理

从 core 内部往外,一步步把这条边界搭出来。

**core 先存什么。** 一条消息要么来自用户,要么来自 provider。provider 的回复还要带停止原因,这样 core 才知道这一轮是正常结束,还是出了错。
**core 先存什么。** 一条消息要么来自用户,要么来自 provider 。provider 的回复还要带停止原因,这样 core 才知道这一轮是正常结束,还是出了错。

```ts
export type StopReason = "stop" | "error";
Expand All @@ -56,7 +59,7 @@ export type AssistantMessage = { role: "assistant"; content: string; stopReason:
export type AgentMessage = UserMessage | AssistantMessage;
```

core 用一个数组按顺序把它们存起来。现在 state 只有一个字段,但后面所有的对话历史都从这里长出来
core 用一个数组按顺序把它们存起来。现在 state 只有一个字段,但后面所有的对话历史都会从这里长出来

```ts
export type AgentState = { messages: AgentMessage[] };
Expand All @@ -65,7 +68,7 @@ export function createInitialState(): AgentState { return { messages: [] }; }
export function createUserMessage(content: string): UserMessage { return { role: "user", content }; }
```

**然后是边界。** provider 不直接拿 AgentState,而是把每条消息转成它需要的 role 和 content,组成 ProviderInput。这一步只是做了一次转换,但它就是那道墙——core 的内部结构不会漏给 provider。
**然后是边界。** provider 不会直接拿到 AgentState,而是把每条消息转成 provider 需要的 role 和 content,组成 ProviderInput。这一步看起来只是做了格式转换,但它就是那道墙 —— core 的内部结构不会漏给 provider

```ts
export type ProviderMessage = { role: "user" | "assistant"; content: string };
Expand All @@ -78,22 +81,22 @@ export function buildProviderInput(state: AgentState): ProviderInput {
}
```

provider 这边的约定就一句话:给我 ProviderInput,我还你 AssistantMessage。
provider 这边的约定就一句话:给我 ProviderInput,我还你 AssistantMessage

```ts
export interface Provider {
complete(input: ProviderInput): Promise<AssistantMessage>;
}
```

**最后收口。** core 不直接决定结果怎么展示,先留一层 Output——现在它只是包了一下 console,但把这层单独拎出来,后面有用。
**最后收口。** core 不直接决定结果怎么展示,先留一层 Output,现在只包了一层 console,但把这层单独拎出来,后面有用。

```ts
export type Output = { log(line: string): void };
export function createConsoleOutput(): Output { return { log: (line) => console.log(line) }; }
```

一轮的推进就是把上面几步连起来:存入用户消息 → 构造输入 → 调 provider → 存回 assistant 消息。
每一轮的推进就是把上面几步连起来:存入用户消息 → 构造输入 → 调 provider → 存回 assistant 消息。

```ts
export async function runOneTurn(
Expand All @@ -107,7 +110,7 @@ export async function runOneTurn(
}
```

> 这一节真正交付的东西,不是 `runOneTurn` 这个函数,而是那条边界——AgentState 和 ProviderInput 之间的转换。后面每一节都会往 ProviderInput 里加东西,但"core 的内部状态不直接暴露给 provider"这条规矩,从 s01 定下来就不会再变。
> 这一节真正要讲的,不是 `runOneTurn` 这个函数,而是 AgentState 和 ProviderInput 之间那条边界:格式转换。后面每一节都会往 ProviderInput 里加入新东西,但“ core 的内部状态永远不会直接暴露给 provider这条规矩,从 s01 定下来就不会再变。

---

Expand Down Expand Up @@ -156,12 +159,12 @@ npm run s01 -- --case error

## 接入主线

s01 是 mini Pi 的第 1 版,是后面 11 节的地基。本节确立的、后续**只扩展不改写**的永驻基础
s01 是 mini Pi 的起点,是后面 11 节的地基。本节定下来的类型和接口,后面**只扩展不改写**:

| 基础 | 后续怎么演化 |
| --- | --- |
| `UserMessage` / `AssistantMessage` / `AgentMessage` | 消息三类型,union 只增(s04 加 ToolResultMessage) |
| `AssistantMessage.stopReason` | 永驻,取值只增(s04 加 toolUse) |
| `AssistantMessage.stopReason` | 字段不变,取值只增(s04 加 toolUse) |
| `AgentState.messages` | 先是数组,s07 升级为 SessionTree(U1) |
| `ProviderInput` | 字段只增(s02 加 tools、s08 加 systemPrompt);对齐 Pi Context,model 在 AgentState 不进 input |
| `Provider` | s03 从 complete 升级为 stream(U1) |
Expand All @@ -171,7 +174,7 @@ s01 是 mini Pi 的第 1 版,是后面 11 节的地基。本节确立的、后

## 接下来

现在 ProviderInput 里只有 messages。下一节会往里加东西,让 provider 看到的不只是对话,还有 core 能提供的本地能力。进入下一节:[s02](../s02_tool_contract/)。
现在 ProviderInput 里只有 messages ,下一节会加入其他东西,让 provider 看到的不只是对话,还有 core 能提供的本地能力。进入下一节:[s02](../s02_tool_contract/)。

---

Expand All @@ -182,9 +185,9 @@ s01 是 mini Pi 的第 1 版,是后面 11 节的地基。本节确立的、后

### 源码在哪

- `packages/agent/src/types.ts:317` — `AgentState` 类型
- `packages/agent/src/agent.ts:166` — `Agent` 类(状态持有 + 生命周期)
- `packages/agent/src/agent-loop.ts:155` — `runAgentLoop`(核心循环)
- [`packages/agent/src/types.ts:317`](https://github.com/earendil-works/pi/blob/main/packages/agent/src/types.ts#L317) — `AgentState` 类型
- [`packages/agent/src/agent.ts:166`](https://github.com/earendil-works/pi/blob/main/packages/agent/src/agent.ts#L166) — `Agent` 类(状态持有 + 生命周期)
- [`packages/agent/src/agent-loop.ts:155`](https://github.com/earendil-works/pi/blob/main/packages/agent/src/agent-loop.ts#L155) — `runAgentLoop`(核心循环)

### AgentState 的真实形状

Expand Down Expand Up @@ -238,11 +241,11 @@ private async runWithLifecycle(executor) {
- **outer loop**:消费 follow-up 消息队列(用户后续追加的话)。
- **inner loop**:处理工具调用,以及 steering 消息(执行中途插入、用来"引导"方向的)。

教学版的循环只有"工具来回"一条线;Pi 把"用户中途插话"和"工具来回"分成两个队列,各有优先级——这是真实交互场景必须的(用户不会老老实实等工具跑完)。
教学版的循环只有"工具来回"一条线;Pi 把"用户中途插话"和"工具来回"分成两个队列,各有优先级这是真实交互场景必须的(用户不会老老实实等工具跑完)。

### 消息带时间戳,内容是数组

教学版 `content` 是字符串。Pi 的 `AgentMessage` 是 `content: Array<TextContent | ImageContent | ToolCall>` 加 `timestamp`——一条消息能同时含文本、图片、工具调用,且按时间排序。教学版先把 content 简化成 string,s04 加 tool_call 时才会碰到"一条消息多种内容"的真实形态。
教学版 `content` 是字符串。Pi 的 `AgentMessage` 是 `content: Array<TextContent | ImageContent | ToolCall>` 加 `timestamp`一条消息能同时含文本、图片、工具调用,且按时间排序。教学版先把 content 简化成 string,s04 加 tool_call 时才会碰到"一条消息多种内容"的真实形态。

### 一句话

Expand Down