cli介绍

Python 复制代码
"""命令行入口。

这个模块负责把"用户怎么启动 pico"翻译成 runtime 能理解的对象:
解析参数、挑模型后端、构建工作区快照、恢复或新建 session,
最后进入 one-shot 或交互式循环。
"""

import argparse
import os
import shutil
import sys
import textwrap

from .config import load_project_env, provider_env
from .models import AnthropicCompatibleModelClient, OllamaModelClient, OpenAICompatibleModelClient
from .runtime import Pico, SessionStore
from .workspace import WorkspaceContext, middle

DEFAULT_SECRET_ENV_NAMES = (
    "PICO_OPENAI_API_KEY",
    "OPENAI_API_KEY",
    "OPENAI_API_TOKEN",
    "PICO_ANTHROPIC_API_KEY",
    "ANTHROPIC_API_KEY",
    "ANTHROPIC_AUTH_TOKEN",
    "PICO_DEEPSEEK_API_KEY",
    "DEEPSEEK_API_KEY",
    "PICO_RIGHT_CODES_API_KEY",
    "RIGHT_CODES_API_KEY",
    "GITHUB_PAT",
    "GH_PAT",
)

WELCOME_ART = (
    "        /\\___/\\\\",
    "       (  o o  )",
    "       /   ^   \\\\",
    "      /|       |\\\\",
)
WELCOME_NAME = "pico"
WELCOME_SUBTITLE = "local coding agent"
WELCOME_STATUS = "calm shell, ready for work"
HELP_DETAILS = textwrap.dedent(
    """\
    Commands:
    /help    Show this help message.
    /memory  Show the agent's distilled working memory.
    /session Show the path to the saved session file.
    /reset   Clear the current session history and memory.
    /exit    Exit the agent.
    """
).strip()


DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"
DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"
DEFAULT_OPENAI_MODEL = "gpt-5.4"
DEFAULT_OPENAI_BASE_URL = "https://www.right.codes/codex/v1"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
DEFAULT_ANTHROPIC_BASE_URL = "https://www.right.codes/claude/v1"
DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-pro"
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com/anthropic"
LEGACY_SECRET_ENV_NAMES_VAR = "MINI_CODING_AGENT_SECRET_ENV_NAMES"
SECRET_ENV_NAMES_VAR = "PICO_SECRET_ENV_NAMES"


def _effective_model(args, provider):
    # 模型选择优先级:
    # 1. 用户显式传入 --model
    # 2. provider 对应的环境变量
    # 3. 代码里的默认值
    explicit_model = getattr(args, "model", None)
    if explicit_model:
        return explicit_model
    if provider == "openai":
        model = provider_env("PICO_OPENAI_MODEL", ("OPENAI_MODEL",))
        if model:
            return model
        return DEFAULT_OPENAI_MODEL
    if provider == "anthropic":
        model = provider_env("PICO_ANTHROPIC_MODEL", ("ANTHROPIC_MODEL",))
        if model:
            return model
        return DEFAULT_ANTHROPIC_MODEL
    if provider == "deepseek":
        model = provider_env("PICO_DEEPSEEK_MODEL", ("DEEPSEEK_MODEL",))
        if model:
            return model
        return DEFAULT_DEEPSEEK_MODEL
    return DEFAULT_OLLAMA_MODEL


def _configured_secret_names(args):
    configured_secret_names = set(DEFAULT_SECRET_ENV_NAMES)
    configured_secret_names.update(str(name).upper() for name in args.secret_env_names)
    extra_names = os.environ.get(SECRET_ENV_NAMES_VAR, "")
    if not extra_names.strip():
        extra_names = os.environ.get(LEGACY_SECRET_ENV_NAMES_VAR, "")
    if extra_names.strip():
        configured_secret_names.update(
            item.strip().upper()
            for item in extra_names.split(",")
            if item.strip()
        )
    return sorted(configured_secret_names)


def _build_model_client(args):
    provider = getattr(args, "provider", "openai")
    # CLI 只负责把 provider 选择翻译成具体 client。
    # 真正的提示词格式、缓存支持、HTTP 协议差异,都封装在 models.py 里。
    if provider == "openai":
        model = _effective_model(args, provider)
        base_url = getattr(args, "base_url", None) or provider_env("PICO_OPENAI_API_BASE", ("OPENAI_API_BASE",), DEFAULT_OPENAI_BASE_URL)
        api_key = provider_env("PICO_OPENAI_API_KEY", ("OPENAI_API_KEY",))
        return OpenAICompatibleModelClient(
            model=model,
            base_url=base_url,
            api_key=api_key,
            temperature=args.temperature,
            timeout=getattr(args, "openai_timeout", getattr(args, "ollama_timeout", 300)),
        )
    if provider == "anthropic":
        model = _effective_model(args, provider)
        base_url = getattr(args, "base_url", None) or provider_env("PICO_ANTHROPIC_API_BASE", ("ANTHROPIC_API_BASE",), DEFAULT_ANTHROPIC_BASE_URL)
        api_key = provider_env(
            "PICO_ANTHROPIC_API_KEY",
            ("ANTHROPIC_API_KEY", "PICO_RIGHT_CODES_API_KEY", "RIGHT_CODES_API_KEY", "PICO_OPENAI_API_KEY", "OPENAI_API_KEY"),
        )
        return AnthropicCompatibleModelClient(
            model=model,
            base_url=base_url,
            api_key=api_key,
            temperature=args.temperature,
            timeout=getattr(args, "openai_timeout", getattr(args, "ollama_timeout", 300)),
        )
    if provider == "deepseek":
        model = _effective_model(args, provider)
        base_url = getattr(args, "base_url", None) or provider_env("PICO_DEEPSEEK_API_BASE", ("DEEPSEEK_API_BASE",), DEFAULT_DEEPSEEK_BASE_URL)
        api_key = provider_env("PICO_DEEPSEEK_API_KEY", ("DEEPSEEK_API_KEY",))
        return AnthropicCompatibleModelClient(
            model=model,
            base_url=base_url,
            api_key=api_key,
            temperature=args.temperature,
            timeout=getattr(args, "openai_timeout", getattr(args, "ollama_timeout", 300)),
        )

    model = _effective_model(args, provider)
    host = getattr(args, "host", DEFAULT_OLLAMA_HOST)
    return OllamaModelClient(
        model=model,
        host=host,
        temperature=args.temperature,
        top_p=args.top_p,
        timeout=args.ollama_timeout,
    )


def build_welcome(agent, model, host):
    width = max(68, min(shutil.get_terminal_size((80, 20)).columns, 84))
    inner = width - 4
    gap = 3
    left_width = (inner - gap) // 2
    right_width = inner - gap - left_width

    def row(text):
        body = middle(text, width - 4)
        return f"| {body.ljust(width - 4)} |"

    def divider(char="-"):
        return "+" + char * (width - 2) + "+"

    def center(text):
        body = middle(text, inner)
        return f"| {body.center(inner)} |"

    def cell(label, value, size):
        body = middle(f"{label:<9} {value}", size)
        return body.ljust(size)

    def pair(left_label, left_value, right_label, right_value):
        left = cell(left_label, left_value, left_width)
        right = cell(right_label, right_value, right_width)
        return f"| {left}{' ' * gap}{right} |"

    line = divider("=")
    rows = [center(text) for text in WELCOME_ART]
    rows.extend(
        [
            center(WELCOME_NAME),
            center(WELCOME_SUBTITLE),
            center(WELCOME_STATUS),
            divider("-"),
            row(""),
            row("WORKSPACE  " + middle(agent.workspace.cwd, inner - 11)),
            pair("MODEL", model, "BRANCH", agent.workspace.branch),
            pair("APPROVAL", agent.approval_policy, "SESSION", agent.session["id"]),
            row(""),
        ]
    )
    return "\n".join([line, *rows, line])


def build_agent(args):
    """根据 CLI 参数装配出一个可运行的 Pico 实例。

    为什么存在:
    命令行参数只是字符串和开关,runtime 需要的是已经装配好的对象图:
    model client、workspace snapshot、session store、secret 配置等。
    这个函数负责把"启动参数"翻译成"agent 运行现场"。

    输入 / 输出:
    - 输入:`argparse` 解析后的 `args`
    - 输出:一个新的 `Pico`,或一个从旧 session 恢复出来的 `Pico`

    在 agent 链路里的位置:
    它是整个程序启动链路里最靠近 runtime 的装配点。`main()` 先调它,
    得到 agent 后,后面无论是 one-shot 还是 REPL 模式,都会落到 `ask()`。
    """
    # 这里是 CLI 到 runtime 的装配点:
    # 先采集工作区快照和加载项目级环境,再整理 secret 名单、模型后端和 session。
    workspace = WorkspaceContext.build(args.cwd)
    load_project_env(workspace.repo_root)
    configured_secret_names = _configured_secret_names(args)
    store = SessionStore(workspace.repo_root + "/.pico/sessions")
    model = _build_model_client(args)
    session_id = args.resume
    if session_id == "latest":
        session_id = store.latest()
    if session_id:
        return Pico.from_session(
            model_client=model,
            workspace=workspace,
            session_store=store,
            session_id=session_id,
            approval_policy=args.approval,
            max_steps=args.max_steps,
            max_new_tokens=args.max_new_tokens,
            secret_env_names=configured_secret_names,
        )
    return Pico(
        model_client=model,
        workspace=workspace,
        session_store=store,
        approval_policy=args.approval,
        max_steps=args.max_steps,
        max_new_tokens=args.max_new_tokens,
        secret_env_names=configured_secret_names,
    )


def build_arg_parser():
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description="Minimal coding agent for Ollama, OpenAI-compatible, Anthropic-compatible, or DeepSeek models.",
    )
    parser.add_argument("prompt", nargs="*", help="Optional one-shot prompt.")
    parser.add_argument("--cwd", default=".", help="Workspace directory.")
    parser.add_argument("--provider", choices=("ollama", "openai", "anthropic", "deepseek"), default="openai", help="Model backend to use.")
    parser.add_argument(
        "--model",
        default=None,
        help="Model name override. Defaults to qwen3.5:4b for Ollama, PICO_OPENAI_MODEL for openai, PICO_ANTHROPIC_MODEL for anthropic, and PICO_DEEPSEEK_MODEL for deepseek when set.",
    )
    parser.add_argument("--host", default=DEFAULT_OLLAMA_HOST, help="Ollama server URL.")
    parser.add_argument("--base-url", default=None, help="Provider API base URL for openai, anthropic, or deepseek.")
    parser.add_argument("--ollama-timeout", type=int, default=300, help="Ollama request timeout in seconds.")
    parser.add_argument("--openai-timeout", type=int, default=300, help="OpenAI-compatible request timeout in seconds.")
    parser.add_argument("--resume", default=None, help="Session id to resume or 'latest'.")
    parser.add_argument("--approval", choices=("ask", "auto", "never"), default="ask", help="Approval policy for risky tools.")
    parser.add_argument(
        "--secret-env-name",
        dest="secret_env_names",
        action="append",
        default=[],
        help="Extra environment variable names to treat as secrets for trace/report redaction.",
    )
    parser.add_argument("--max-steps", type=int, default=6, help="Maximum tool/model iterations per request.")
    parser.add_argument("--max-new-tokens", type=int, default=512, help="Maximum model output tokens per step.")
    parser.add_argument("--temperature", type=float, default=0.2, help="Sampling temperature sent to Ollama.")
    parser.add_argument("--top-p", type=float, default=0.9, help="Top-p sampling value sent to Ollama.")
    return parser


def main(argv=None):
    args = build_arg_parser().parse_args(argv)
    agent = build_agent(args)

    model = getattr(agent.model_client, "model", getattr(args, "model", DEFAULT_OLLAMA_MODEL))
    host = getattr(agent.model_client, "host", getattr(agent.model_client, "base_url", getattr(args, "host", DEFAULT_OLLAMA_HOST)))
    print(build_welcome(agent, model=model, host=host))

    if args.prompt:
        # one-shot 模式:只跑一次 ask,不进入 REPL 循环。
        prompt = " ".join(args.prompt).strip()
        if prompt:
            print()
            try:
                print(agent.ask(prompt))
            except RuntimeError as exc:
                print(str(exc), file=sys.stderr)
                return 1
        return 0

    while True:
        # 交互模式:每次读取一条用户输入,交给同一个 agent,
        # 因此 session history 和 working memory 会跨轮延续。
        try:
            user_input = input("\npico> ").strip()
        except (EOFError, KeyboardInterrupt):
            print("")
            return 0

        if not user_input:
            continue
        if user_input in {"/exit", "/quit"}:
            return 0
        if user_input == "/help":
            print(HELP_DETAILS)
            continue
        if user_input == "/memory":
            print(agent.memory_text())
            continue
        if user_input == "/session":
            print(agent.session_path)
            continue
        if user_input == "/reset":
            agent.reset()
            print("session reset")
            continue

        print()
        try:
            print(agent.ask(user_input))
        except RuntimeError as exc:
            print(str(exc), file=sys.stderr)

【这个模块解决什么问题】

cli.py 解决的是:

用户在命令行里怎么启动 Pico,以及这些启动参数怎么变成一个真正能运行的 Pico agent

它不负责真正的 agent 主循环。真正主循环在 runtime.pyPico.ask()

cli.py 主要负责六件事:

markdown 复制代码
1. 解析命令行参数
2. 确定工作目录 cwd
3. 创建 WorkspaceContext
4. 创建 model client
5. 处理 session / resume
6. 选择 one-shot 或 REPL 模式,然后调用 agent.ask()

所以你要把 cli.py 理解成:

Pico 的启动装配层。


【它在 Pico 主链路里的位置】

上一轮我们讲的主链路是:

scss 复制代码
用户输入请求
→ CLI 解析参数
→ build_agent() 装配 workspace / model / session
→ Pico.ask() 进入主循环
→ 构建 prompt
→ 调用模型
→ parse 模型输出
→ run_tool()
→ 更新 memory / task_state / trace
→ final/report

这一章只覆盖前半段:

scss 复制代码
用户输入请求
→ CLI 解析参数
→ build_agent() 装配 workspace / model / session
→ 调用 Pico.ask()

也就是说,cli.py 做的是"开机准备",不是"agent 思考"。


【我应该打开哪个文件】

你已经贴了 cli.py,这次重点看这些位置:

scss 复制代码
1. build_arg_parser()
2. main()
3. build_agent()
4. _build_model_client()
5. _effective_model()
6. _configured_secret_names()

优先级最高的是:

scss 复制代码
main()
build_agent()
_build_model_client()

这三个函数串起来,就是 Pico CLI 启动链路。


【我应该重点看哪些函数 / 类】

1. build_arg_parser()

它负责声明 Pico 支持哪些命令行参数。

核心参数有:

go 复制代码
parser.add_argument("prompt", nargs="*", help="Optional one-shot prompt.")
parser.add_argument("--cwd", default=".", help="Workspace directory.")
parser.add_argument("--provider", choices=("ollama", "openai", "anthropic", "deepseek"), default="openai")
parser.add_argument("--model", default=None)
parser.add_argument("--resume", default=None)
parser.add_argument("--approval", choices=("ask", "auto", "never"), default="ask")
parser.add_argument("--max-steps", type=int, default=6)
parser.add_argument("--max-new-tokens", type=int, default=512)

你先别背参数,抓主线:

css 复制代码
prompt       用户给 agent 的任务
--cwd        让 agent 在哪个目录下工作
--provider   用哪个模型服务
--model      用哪个具体模型
--resume     是否恢复旧 session
--approval   高风险工具怎么审批
--max-steps  agent 最多循环几轮

这里最关键的是:

ini 复制代码
parser.add_argument("prompt", nargs="*")

nargs="*" 的意思是:命令行最后可以接多个词,都会被收集成列表。

比如:

matlab 复制代码
pico fix README typo

解析后大概是:

ini 复制代码
args.prompt = ["fix", "README", "typo"]

后面在 main() 里会被拼成:

ini 复制代码
prompt = " ".join(args.prompt).strip()

也就是:

matlab 复制代码
fix README typo

所以 prompt 是否存在,决定了 Pico 是 one-shot 还是 REPL。


2. main()

main() 是真正入口。

它的结构非常清楚:

ini 复制代码
def main(argv=None):
    args = build_arg_parser().parse_args(argv)
    agent = build_agent(args)

第一句:

scss 复制代码
args = build_arg_parser().parse_args(argv)

意思是:

把命令行字符串解析成一个 args 对象。

比如你运行:

bash 复制代码
pico --provider openai --cwd ./my_project "帮我看 README"

大概会变成:

ini 复制代码
args.provider = "openai"
args.cwd = "./my_project"
args.prompt = ["帮我看 README"]

第二句:

ini 复制代码
agent = build_agent(args)

意思是:

根据这些参数装配出一个真正的 Pico 实例。

注意:main() 不自己创建 workspace,不自己创建 model client,也不自己处理 session。它把这些工作交给 build_agent()

然后打印欢迎界面:

ini 复制代码
print(build_welcome(agent, model=model, host=host))

这个只是 UI,不是核心逻辑。

接下来出现关键分支。


【代码大概怎么跑】

第一条路线:one-shot 模式

代码是:

css 复制代码
if args.prompt:
    prompt = " ".join(args.prompt).strip()
    if prompt:
        print(agent.ask(prompt))
    return 0

这表示:如果命令行里直接带了任务,就只执行一次 agent.ask()

比如:

arduino 复制代码
pico "总结这个项目怎么启动"

流程是:

scss 复制代码
解析参数
→ build_agent()
→ agent.ask("总结这个项目怎么启动")
→ 打印结果
→ 程序退出

这叫 one-shot。

它适合:

bash 复制代码
一次性任务
脚本调用
benchmark / eval
CI 里跑 agent

第二条路线:REPL 模式

如果你没有传 prompt,比如:

复制代码
pico

就会进入:

python 复制代码
while True:
    user_input = input("\npico> ").strip()

这就是交互模式。

它会一直等你输入:

shell 复制代码
pico> 帮我看 README
pico> 再看一下 setup.py
pico> 继续改一下错误

每次普通输入都会走:

scss 复制代码
print(agent.ask(user_input))

关键点是:REPL 里用的是同一个 agent

所以注释里说:

shell 复制代码
# 因此 session history 和 working memory 会跨轮延续。

这句话很重要。

one-shot 是:

复制代码
一个 prompt → ask 一次 → 结束

REPL 是:

bash 复制代码
同一个 agent
→ 多次用户输入
→ 多次 ask()
→ history/memory 延续

【build_agent() 是这个文件的核心】

现在看最关键的函数:

ini 复制代码
def build_agent(args):
    workspace = WorkspaceContext.build(args.cwd)
    load_project_env(workspace.repo_root)
    configured_secret_names = _configured_secret_names(args)
    store = SessionStore(workspace.repo_root + "/.pico/sessions")
    model = _build_model_client(args)
    session_id = args.resume

这一段就是 CLI 到 runtime 的装配点。

你可以按顺序理解。


1. cwd 是怎么确定的?

参数里有:

arduino 复制代码
parser.add_argument("--cwd", default=".", help="Workspace directory.")

默认是:

erlang 复制代码
.

也就是你启动 Pico 时所在的当前目录。

然后这里用它构建 workspace:

ini 复制代码
workspace = WorkspaceContext.build(args.cwd)

这一步不是简单保存字符串。

它大概率会做这些事:

复制代码
把 cwd 解析成绝对路径
识别 repo_root
识别 git branch
记录工作区信息

从后面的欢迎界面也能看出来,agent.workspace 至少有:

复制代码
agent.workspace.cwd
agent.workspace.branch

所以 WorkspaceContext 不是普通路径变量,而是:

Pico 对当前代码工作区的结构化描述。

这对 coding agent 很重要,因为工具读写文件、路径安全、trace/report 都要知道"工作区边界"。


2. 项目环境变量什么时候加载?

紧接着:

scss 复制代码
load_project_env(workspace.repo_root)

顺序很关键。

它不是一开始就加载环境变量,而是先得到:

复制代码
workspace.repo_root

然后再去加载项目级 env。

为什么?

因为 .env 通常在项目根目录。

所以链路是:

scss 复制代码
args.cwd
→ WorkspaceContext.build(args.cwd)
→ 得到 repo_root
→ load_project_env(repo_root)

这说明 Pico 的环境变量不是全局乱找,而是跟当前项目绑定。


3. secret 是怎么配置的?

先看默认名单:

makefile 复制代码
DEFAULT_SECRET_ENV_NAMES = (
    "PICO_OPENAI_API_KEY",
    "OPENAI_API_KEY",
    ...
    "GITHUB_PAT",
    "GH_PAT",
)

然后:

ini 复制代码
configured_secret_names = _configured_secret_names(args)

_configured_secret_names() 做三层合并:

diff 复制代码
默认 secret 名单
+ 命令行 --secret-env-name 传进来的名字
+ 环境变量 PICO_SECRET_ENV_NAMES 里配置的名字

它的目的不是"创建 API key"。

它的目的更像是:

告诉 Pico 哪些环境变量名字是敏感信息,后面写 trace/report 的时候要做脱敏。

这就是 harness 味道。

普通 demo 可能直接把日志打印出来。

Pico 会考虑:

vbnet 复制代码
工具输出里会不会泄露 token?
trace 里会不会写进 API key?
report 里会不会暴露 secret?

4. SessionStore 是什么时候创建的?

这一句:

ini 复制代码
store = SessionStore(workspace.repo_root + "/.pico/sessions")

说明 session 放在项目根目录下面:

bash 复制代码
.pico/sessions

这一步很关键,因为它把 session 和项目绑定了。

不是所有项目共用一个 session 目录,而是:

bash 复制代码
当前 repo_root/.pico/sessions

所以你在不同项目里启动 Pico,session 存储位置不同。


5. model client 是怎么创建的?

这一句:

ini 复制代码
model = _build_model_client(args)

进入 _build_model_client()

它先读:

ini 复制代码
provider = getattr(args, "provider", "openai")

然后根据 provider 分流:

复制代码
openai    → OpenAICompatibleModelClient
anthropic → AnthropicCompatibleModelClient
deepseek  → AnthropicCompatibleModelClient
ollama    → OllamaModelClient

这里你要理解一个设计点:

CLI 不直接调用模型 API,它只根据参数创建一个 model client 对象。

为什么?

因为 OpenAI、Anthropic、Ollama、DeepSeek 的 HTTP 协议、base_url、key、参数格式都不一样。

但 runtime 不应该关心这些差异。

runtime 后面只想做:

scss 复制代码
model_client.generate(...)

或者类似调用。

所以 _build_model_client() 是适配层。


6. 模型名优先级是什么?

_effective_model(args, provider)

它的优先级是:

markdown 复制代码
1. 用户命令行显式传 --model
2. provider 对应的环境变量
3. 代码默认值

比如 provider 是 openai:

kotlin 复制代码
if explicit_model:
    return explicit_model

model = provider_env("PICO_OPENAI_MODEL", ("OPENAI_MODEL",))
if model:
    return model

return DEFAULT_OPENAI_MODEL

这很实用。

你可以这样理解:

css 复制代码
pico --model gpt-xxx

优先级最高。

如果没传,就看环境变量:

ini 复制代码
PICO_OPENAI_MODEL=xxx

再没有,才用代码默认:

ini 复制代码
DEFAULT_OPENAI_MODEL = "gpt-5.4"

7. session / resume 是怎么处理的?

核心代码:

ini 复制代码
session_id = args.resume
if session_id == "latest":
    session_id = store.latest()
if session_id:
    return Pico.from_session(...)
return Pico(...)

这里有三种情况。

情况一:不 resume

复制代码
pico

此时:

ini 复制代码
args.resume = None
session_id = None

于是走:

kotlin 复制代码
return Pico(...)

也就是新建一个 Pico 实例。


情况二:恢复指定 session

css 复制代码
pico --resume abc123

此时:

ini 复制代码
session_id = "abc123"

于是走:

ini 复制代码
return Pico.from_session(..., session_id="abc123")

也就是从旧 session 恢复。


情况三:恢复最新 session

css 复制代码
pico --resume latest

此时:

ini 复制代码
if session_id == "latest":
    session_id = store.latest()

store.latest() 会找到最近的 session id,然后:

scss 复制代码
Pico.from_session(...)

这说明 resume 不是在 CLI 里手动读 JSON。

CLI 只是告诉 runtime:

复制代码
我要恢复哪个 session

真正恢复逻辑交给:

scss 复制代码
Pico.from_session()

这是合理的职责划分。


【build_agent() 最终返回了什么】

build_agent() 最终只返回两种东西之一:

新 agent

ini 复制代码
return Pico(
    model_client=model,
    workspace=workspace,
    session_store=store,
    approval_policy=args.approval,
    max_steps=args.max_steps,
    max_new_tokens=args.max_new_tokens,
    secret_env_names=configured_secret_names,
)

从旧 session 恢复的 agent

ini 复制代码
return Pico.from_session(
    model_client=model,
    workspace=workspace,
    session_store=store,
    session_id=session_id,
    approval_policy=args.approval,
    max_steps=args.max_steps,
    max_new_tokens=args.max_new_tokens,
    secret_env_names=configured_secret_names,
)

所以你要记住:

build_agent() 返回的不是答案,也不是模型 client,而是一个已经装配好的 Pico runtime 对象。

后面无论 one-shot 还是 REPL,都会调用:

scss 复制代码
agent.ask(...)

这就是 CLI 和 runtime 的连接点。


【用一个例子讲一遍】

假设你运行:

css 复制代码
pico --cwd ./demo --provider openai --resume latest "帮我总结 README"

这条命令会这样走:

scss 复制代码
1. main() 开始
2. build_arg_parser().parse_args(argv)
3. 得到 args

大概是:

ini 复制代码
args.cwd = "./demo"
args.provider = "openai"
args.resume = "latest"
args.prompt = ["帮我总结 README"]

然后:

scss 复制代码
4. build_agent(args)

里面继续:

scss 复制代码
WorkspaceContext.build("./demo")
→ 确定 cwd / repo_root / branch

load_project_env(workspace.repo_root)
→ 加载项目级环境变量

_configured_secret_names(args)
→ 整理需要脱敏的 secret 名字

SessionStore(repo_root + "/.pico/sessions")
→ 创建 session 存储器

_build_model_client(args)
→ 创建 OpenAICompatibleModelClient

args.resume == "latest"
→ store.latest()
→ 找到最近 session id

Pico.from_session(...)
→ 恢复旧 agent

然后回到 main()

css 复制代码
if args.prompt:
    prompt = " ".join(args.prompt).strip()
    print(agent.ask(prompt))
    return 0

所以最后会调用:

arduino 复制代码
agent.ask("帮我总结 README")

从这里开始,才进入下一章 runtime.py 的主循环。


【one-shot 和 REPL 的区别是什么】

这个你必须吃透。

one-shot

运行方式:

arduino 复制代码
pico "帮我修复 README"

代码分支:

bash 复制代码
if args.prompt:
    print(agent.ask(prompt))
    return 0

特点:

scss 复制代码
启动一次
执行一次 ask()
打印答案
退出程序

适合:

复制代码
一次性任务
自动化脚本
评测 benchmark
CI 流程

REPL

运行方式:

复制代码
pico

代码分支:

css 复制代码
while True:
    user_input = input("\npico> ").strip()
    ...
    print(agent.ask(user_input))

特点:

scss 复制代码
启动一次
同一个 agent 留在内存里
用户可以多轮输入
每轮都调用 ask()
history / memory 可以延续

而且 REPL 支持内部命令:

bash 复制代码
/help     查看命令
/memory   查看 working memory
/session  查看 session 路径
/reset    清空当前 session history 和 memory
/exit     退出

这个设计说明 Pico 不是只服务一次模型调用,而是支持一个长期交互式 coding session。


【和 LangChain / LangGraph / Claude Code 的对应关系】

对应 LangChain

cli.py 这一层类似于你写 LangChain demo 前面的初始化代码:

ini 复制代码
llm = ChatOpenAI(...)
tools = [...]
agent = create_agent(...)

但 Pico 更工程化。

它不只是创建 LLM,还创建:

arduino 复制代码
workspace
session store
secret redaction config
approval policy
max step limit
model provider adapter

所以它更接近真实 coding agent 的启动器。


对应 LangGraph

LangGraph 里你会先编译 graph:

ini 复制代码
app = graph.compile(checkpointer=...)

Pico 的 build_agent() 有点像:

复制代码
把 runtime 所需状态和组件装配好

只不过 Pico 没有显式 graph,而是后面用 Pico.ask() 手写 agent loop。


对应 Claude Code

Claude Code 启动时也要知道:

复制代码
当前项目目录
当前会话
模型配置
工具权限
是否继续历史会话

Pico 的 cli.py 就是在做一个迷你版 Claude Code 启动层。

你以后面试可以说:

我不是只看模型调用,而是看了 coding agent 从 CLI 到 runtime 的装配过程,包括 workspace、model client、session store 和 approval policy 的初始化。

这句话比"我会调 API"强很多。


【一句面试话术】

cli.py 是 Pico 的启动装配层,它把命令行参数解析成 runtime 需要的对象图:先根据 --cwd 构建 WorkspaceContext,再加载项目环境变量、创建模型 client 和 SessionStore,根据 --resume 决定新建或恢复 Pico,最后进入 one-shot 或 REPL,并统一通过 agent.ask() 进入真正的 agent 主循环。


【自测问题】

你先回答这 3 个问题。

1.

为什么 build_agent() 不直接调用模型,而是只返回一个 Pico 对象?


2.

--cwd 为什么不能只是一个普通字符串?为什么要变成 WorkspaceContext


3.

one-shot 和 REPL 最大的区别是什么?尤其是为什么 REPL 里的 memory/history 可以延续?


【下一步看什么】

等你答完,我会根据你的回答追问或补一轮。

如果你答得可以,下一步我们进入:

bash 复制代码
pico/runtime.py

重点看:

scss 复制代码
Pico.ask()
parse()
run_tool()
trace/report/task_state 更新位置
相关推荐
一 乐13 小时前
个人博客系统|基于Springboot的个人博客系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·个人博客系统
Nyarlathotep011313 小时前
自动内存管理(3):HotSpot中垃圾收集的实现
jvm·后端
神奇小汤圆13 小时前
一行代码干翻 Java 反射?EggG 流式反射调用让反射优雅到不可思议
后端
CodeSheep13 小时前
苦撑13年,创始人离职出走,拉勾终究还是倒下了…
前端·后端·程序员
白宇横流学长13 小时前
基于Spring Boot的社区生鲜团购系统设计与实现
java·spring boot·后端
程序员cxuan13 小时前
Claude Opus 4.8 来了,我感觉更像 4.7 满血版
人工智能·后端·程序员
特立独行的猫a13 小时前
Rust+ Tauri实现漂亮小巧的Mqtt客户端工具--AtomMQTT Client 实现详解
开发语言·后端·mqtt·rust
Gopher_HBo13 小时前
Go语言学习笔记(二)
后端