cli介绍

11 阅读15分钟
"""命令行入口。

这个模块负责把“用户怎么启动 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 主要负责六件事:

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

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

Pico 的启动装配层。


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

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

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

这一章只覆盖前半段:

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

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


【我应该打开哪个文件】

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

1. build_arg_parser()
2. main()
3. build_agent()
4. _build_model_client()
5. _effective_model()
6. _configured_secret_names()

优先级最高的是:

main()
build_agent()
_build_model_client()

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


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

1. build_arg_parser()

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

核心参数有:

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)

你先别背参数,抓主线:

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

这里最关键的是:

parser.add_argument("prompt", nargs="*")

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

比如:

pico fix README typo

解析后大概是:

args.prompt = ["fix", "README", "typo"]

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

prompt = " ".join(args.prompt).strip()

也就是:

fix README typo

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


2. main()

main() 是真正入口。

它的结构非常清楚:

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

第一句:

args = build_arg_parser().parse_args(argv)

意思是:

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

比如你运行:

pico --provider openai --cwd ./my_project "帮我看 README"

大概会变成:

args.provider = "openai"
args.cwd = "./my_project"
args.prompt = ["帮我看 README"]

第二句:

agent = build_agent(args)

意思是:

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

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

然后打印欢迎界面:

print(build_welcome(agent, model=model, host=host))

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

接下来出现关键分支。


【代码大概怎么跑】

第一条路线:one-shot 模式

代码是:

if args.prompt:
    prompt = " ".join(args.prompt).strip()
    if prompt:
        print(agent.ask(prompt))
    return 0

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

比如:

pico "总结这个项目怎么启动"

流程是:

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

这叫 one-shot。

它适合:

一次性任务
脚本调用
benchmark / eval
CI 里跑 agent

第二条路线:REPL 模式

如果你没有传 prompt,比如:

pico

就会进入:

while True:
    user_input = input("\npico> ").strip()

这就是交互模式。

它会一直等你输入:

pico> 帮我看 README
pico> 再看一下 setup.py
pico> 继续改一下错误

每次普通输入都会走:

print(agent.ask(user_input))

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

所以注释里说:

# 因此 session history 和 working memory 会跨轮延续。

这句话很重要。

one-shot 是:

一个 prompt → ask 一次 → 结束

REPL 是:

同一个 agent
→ 多次用户输入
→ 多次 ask()
→ history/memory 延续

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

现在看最关键的函数:

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 是怎么确定的?

参数里有:

parser.add_argument("--cwd", default=".", help="Workspace directory.")

默认是:

.

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

然后这里用它构建 workspace:

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. 项目环境变量什么时候加载?

紧接着:

load_project_env(workspace.repo_root)

顺序很关键。

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

workspace.repo_root

然后再去加载项目级 env。

为什么?

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

所以链路是:

args.cwd
→ WorkspaceContext.build(args.cwd)
→ 得到 repo_root
→ load_project_env(repo_root)

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


3. secret 是怎么配置的?

先看默认名单:

DEFAULT_SECRET_ENV_NAMES = (
    "PICO_OPENAI_API_KEY",
    "OPENAI_API_KEY",
    ...
    "GITHUB_PAT",
    "GH_PAT",
)

然后:

configured_secret_names = _configured_secret_names(args)

_configured_secret_names() 做三层合并:

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

它的目的不是“创建 API key”。

它的目的更像是:

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

这就是 harness 味道。

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

Pico 会考虑:

工具输出里会不会泄露 token?
trace 里会不会写进 API key?
report 里会不会暴露 secret?

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

这一句:

store = SessionStore(workspace.repo_root + "/.pico/sessions")

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

.pico/sessions

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

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

当前 repo_root/.pico/sessions

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


5. model client 是怎么创建的?

这一句:

model = _build_model_client(args)

进入 _build_model_client()

它先读:

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 后面只想做:

model_client.generate(...)

或者类似调用。

所以 _build_model_client() 是适配层。


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

_effective_model(args, provider)

它的优先级是:

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

比如 provider 是 openai:

if explicit_model:
    return explicit_model

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

return DEFAULT_OPENAI_MODEL

这很实用。

你可以这样理解:

pico --model gpt-xxx

优先级最高。

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

PICO_OPENAI_MODEL=xxx

再没有,才用代码默认:

DEFAULT_OPENAI_MODEL = "gpt-5.4"

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

核心代码:

session_id = args.resume
if session_id == "latest":
    session_id = store.latest()
if session_id:
    return Pico.from_session(...)
return Pico(...)

这里有三种情况。

情况一:不 resume

pico

此时:

args.resume = None
session_id = None

于是走:

return Pico(...)

也就是新建一个 Pico 实例。


情况二:恢复指定 session

pico --resume abc123

此时:

session_id = "abc123"

于是走:

return Pico.from_session(..., session_id="abc123")

也就是从旧 session 恢复。


情况三:恢复最新 session

pico --resume latest

此时:

if session_id == "latest":
    session_id = store.latest()

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

Pico.from_session(...)

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

CLI 只是告诉 runtime:

我要恢复哪个 session

真正恢复逻辑交给:

Pico.from_session()

这是合理的职责划分。


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

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

新 agent

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

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,都会调用:

agent.ask(...)

这就是 CLI 和 runtime 的连接点。


【用一个例子讲一遍】

假设你运行:

pico --cwd ./demo --provider openai --resume latest "帮我总结 README"

这条命令会这样走:

1. main() 开始
2. build_arg_parser().parse_args(argv)
3. 得到 args

大概是:

args.cwd = "./demo"
args.provider = "openai"
args.resume = "latest"
args.prompt = ["帮我总结 README"]

然后:

4. build_agent(args)

里面继续:

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()

if args.prompt:
    prompt = " ".join(args.prompt).strip()
    print(agent.ask(prompt))
    return 0

所以最后会调用:

agent.ask("帮我总结 README")

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


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

这个你必须吃透。

one-shot

运行方式:

pico "帮我修复 README"

代码分支:

if args.prompt:
    print(agent.ask(prompt))
    return 0

特点:

启动一次
执行一次 ask()
打印答案
退出程序

适合:

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

REPL

运行方式:

pico

代码分支:

while True:
    user_input = input("\npico> ").strip()
    ...
    print(agent.ask(user_input))

特点:

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

而且 REPL 支持内部命令:

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

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


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

对应 LangChain

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

llm = ChatOpenAI(...)
tools = [...]
agent = create_agent(...)

但 Pico 更工程化。

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

workspace
session store
secret redaction config
approval policy
max step limit
model provider adapter

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


对应 LangGraph

LangGraph 里你会先编译 graph:

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 可以延续?


【下一步看什么】

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

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

pico/runtime.py

重点看:

Pico.ask()
parse()
run_tool()
trace/report/task_state 更新位置