OpenProgram Docs

统一 session 上下文创建#

问题#

OpenProgram 的几个核心能力——函数 docstring 自动进 promptDAG 持久化ask_user 追踪嵌套调用的 called_by 归属——都依赖一个 per-turn 的 session 上下文:一组 ContextVar(_store / _current_turn_id / _current_runtime / _call_id),由 SessionDB + session_id 构成。

这套上下文只有 dispatcher(网页 / chat 入口)在建,而且建立逻辑全部内联在 process_user_turn() 里,没有抽成可复用函数。后果:

  • 命令行直跑的入口(research harness main.py)根本没建 session_store=None → 上述能力全部静默失效。具体表现:design_experiments / write_section 的 docstring 写了详细指令,但命令行跑时模型收不到 → 退化成"What would you like to do"对话。同一个函数,网页端正常、命令行不正常。
  • process_runner(子进程)和 tests 各自手抄了一遍 dispatcher 的 set/reset 逻辑(process_runner.py 约 190-230 行、tests/.../test_runtime_exec_dag.py)—— 证明这块缺一个共享单元。

核心矛盾:装了 OpenProgram,不管命令行还是网页,行为就该一致。现在不一致, 根因是"建 session"这件事没有统一入口,谁记得做谁做、CLI 忘了做。

standalone(_store=None)下静默失效的能力清单#

能力 失效点 表现
docstring 进 prompt render.py:101(走不到) 函数指令到不了模型,退化成对话
DAG 持久化 runtime append 节点 no-op 跑的过程不进 session 历史
从 DAG 渲染历史 _render_history_messages return None 每次 exec 看不到前面步骤
ask_user 追踪 placeholder/finish 节点不写 提问不入历史
嵌套 called_by _call_id=None 节点归属丢失

实现模式统一是"store=_store.get(); if store is None: return"——不崩,但什么都不做。

session 上下文由什么组成(调查结论)#

SessionDB (default_db / SessionStore)           持久化后端(per-session git 仓库)
   └─ ~/.openprogram/sessions/<session_id>/      history/ + context/ + meta.json
SessionNodeWriter(db, session_id)                   瘦包装:append/update/load DAG 节点
ContextVars(per turn,必须 set+reset 配对):
   _store           = SessionNodeWriter(...)         深层代码读它写 DAG / 渲染 doc
   _current_turn_id = assistant_msg_id            文件备份归属到哪条消息
   _current_runtime = create_runtime()            @agentic_function 自动注入用
   _call_id         = (由 @agentic_function 包装设)  节点 called_by 归属

work-dir 与 session 是两套独立持久化:work-dir 存 research 产物文件 (literature review/ ideas/ paper/),session 存对话 DAG。互补,不冲突——接 session 不动 work-dir。

设计:抽出统一的 session 上下文管理器#

把 dispatcher 内联的那套抽成一个可复用单元,所有入口(dispatcher / CLI / research / process_runner / tests)都用它。形态用 context manager(保证 set/reset 配对,不泄漏 token):

# openprogram/store/session/context.py  (新文件)

@contextmanager
def session_context(
    session_id: str | None = None,
    *,
    agent_id: str = "main",
    turn_id: str | None = None,
    runtime=None,            # 已有 runtime 就复用,否则按需建
    create_runtime_if_none: bool = True,
):
    """Install the per-turn session ContextVars and tear them down on exit.

    The single place that wires _store / _current_turn_id / _current_runtime
    so docstring-into-prompt, DAG persistence, ask_user tracking all work the
    SAME whether the caller is the web dispatcher, the CLI, research harness,
    a subprocess, or a test. Standalone callers that pass nothing still get a
    real (ad-hoc) session instead of silently degrading.
    """
    db = default_db()
    sid = session_id or ("adhoc_" + _short_uuid())
    if db.get_session(sid) is None:
        db.create_session(sid, agent_id, source="cli")
    rt = runtime
    if rt is None and create_runtime_if_none:
        rt = create_runtime()           # 降级:无 provider 则 rt=None,store 仍装
    tid = turn_id or ("turn_" + _short_uuid())

    tokens = []
    tokens.append(("_store",  _store.set(SessionNodeWriter(db, sid))))
    tokens.append(("_turn",   _current_turn_id.set(tid)))
    if rt is not None:
        tokens.append(("_rt", _current_runtime.set(rt)))
    try:
        yield SessionHandle(db=db, session_id=sid, runtime=rt, turn_id=tid)
    finally:
        for _name, tok in reversed(tokens):
            tok.var.reset(tok)   # 实现里持有 var 引用以便 reset

session 的边界:由 session_id 的传递决定,不是按调用次数#

聊天里 session 边界天然清楚(开聊→结束)。命令行 / client 调用是"无状态的单次 函数调用",边界模糊——不能"一次调用 = 一个 session",否则"跑一次任务、再接着 优化"会裂成两个不相干的 session,历史断掉;反过来也不该把无关的两件事塞进一个。

谁决定"续上次"还是"另起",由调用方显式传 session_id 表达 —— 这正是 OpenProgram 已有的机制(openprogram --resume <id>、webui 前端把 id 带回), 统一 session 只是让所有入口沿用同一套规则,不发明新东西:

调用方意图 传什么 session_context 行为
跑一件新任务 不传 session_id 新建一个,把 id 返回/打印给调用方
接着在同一任务上聊 / 优化(第 2、3 次调用) 传上次返回的那个 id 复用:接着往同一 session 写,历史接上
另起完全无关的事 不传(或传别的 id) 独立新 session

关键决定(回应"不能每调用一次就存一个 session"):session_context 不默认每次 新建 adhoc。 规则是:

  • 传了 session_id → 复用(存在就接着写;不存在就以这个 id 建)。
  • 没传 → 才新建,且必须把 id 暴露出去(CLI 打印 "session: research_xxx (--resume to continue)";代码 client 在返回值里带 session_id)。

这样连续性由 id 的传递 表达,与调用几次无关:

# 代码 client 的理想用法
r1 = run_research("survey agent reliability")          # session_id=None → 新建
print(r1.session_id)                                   # -> research_ab12cd
r2 = run_research("now turn it into a paper",
                  session_id=r1.session_id)            # 续同一 session,历史接上
r3 = run_research("unrelated: GUI agent benchmark")    # 不传 → 独立新 session

CLI 对应:首跑不带 --session → 打印新 id;--session <id>(或复用 --resume)续。

session 的结束#

session 不需要显式"结束" —— 它是 append-only 的 git 历史,写完就停,下次带同 id 来就接着写。没有"关闭"动作要做(session_context 退出只 reset ContextVar,不删 session)。"结束"只是调用方不再带这个 id 而已。需要清理时由 session 管理(已有的 session 列表 / 删除)处理,不在每次调用的职责里。

各入口怎么用#

  • dispatcher:把现有内联的 set/reset 替换成 with session_context(req.session_id, ...)。 行为不变(它本来就建 session),只是去重。
  • research harness main.py:在调 research_agent 外面包一层——
    with session_context(session_id="research_" + uuid, runtime=rt) as h:
        result = research_agent(task=task, runtime=h.runtime, ...)
    
    这一层让 docstring 机制在命令行也生效,不用改任何 stage 函数、不用把指令搬进 content。这是修复 research 后半段退化的正道。
  • process_runner / tests:用同一个 manager 取代各自手抄的 set/reset。

指令写在 docstring 里#

某些 stage 函数的指令曾为绕开命令行缺失 session 而从 docstring 搬进 content;一旦该 入口装上 session 上下文,指令就回到 docstring 承担。把指令搬进 content 违背"docstring 写指令、函数体干净"的设计意图,而 session 上下文消除了这么做的理由。

一句话#

不是少装了什么组件——是 "建 session" 需要一个所有调用方都走的统一入口,命令行才能 和网页表现一致。

实现状态#

设计分四步落地,每步独立可验证:

做什么 验证 状态
S1 新增 session_context manager(抽 dispatcher 逻辑,行为等价) 单测:进入后 _store/_current_turn_id/_current_runtime 非 None,退出后复位 已完成——openprogram/store/session/context.py
S2 research main.py 用它包住 research_agent 命令行跑一个带详细 docstring 的函数,抓 prompt 确认 docstring 进了 已完成
S3 dispatcher 改用它(去重,行为不变) 现有 dispatcher 测试全绿 未完成——仍内联 set/reset
S4 process_runner / tests 收口到它 子进程 DAG 追踪、测试 fixture 仍工作 未完成——两处仍各自手抄 set/reset

S1、S2 是让 research 命令行工作的部分;S3、S4 消除重复的 set/reset 逻辑。上文问题一节 描述的是 S1 之前的状态。

Last updated · 2026-08-13