English lecture · source-traced

Building an “Open AI for Scientist” from scratch (OpenAI4S lineage) · full development tutorial

**What this tutorial teaches**: not “read another scientific-agent codebase,” but how to **build an OpenAI4S-grade product with your own hands**—a local scientific workbench for wet-lab bioinformaticians and computational chemists: native JSON tools orchestrate and gate permission; a persistent Python/R kernel does scientific execution; a pure-stdlib daemon plus a hand-written WebSocket push results back into chat bu

EN
lecture
source
file:line
no
embellishment
中文
sibling

What this tutorial teaches: not “read another scientific-agent codebase,” but how to build an OpenAI4S-grade product with your own hands—a local scientific workbench for wet-lab bioinformaticians and computational chemists: native JSON tools orchestrate and gate permission; a persistent Python/R kernel does scientific execution; a pure-stdlib daemon plus a hand-written WebSocket push results back into chat bubbles. The most counter-intuitive point: you think you are building “a smarter coding agent”—wrong. You are building a scientific runtime of two action planes stacked together. Even more counter-intuitive: paranoia about the kernel protocol and provenance decides whether the product is trustworthy more than model choice does. The control plane uses auditable JSON Tool calls; the scientific plane uses a full code Cell inside a persistent kernel. And the core engine, the LLM client (urllib), and the Web daemon (http.server + hand-written WS) are all zero third-party dependencies. How it is taught: like this repo’s Open Design lineage, Manus lineage, and OpenWorker lineage—follow a real scene, start from a blank page, and every time you hit a wall, add one part. The voice is Codex product manager + a developer who writes the code: first the trade-off, then how the files land, then how to accept. Against source: baseline 参考项目/OpenAI4S @ commit 85e9fa0 (2026-08, Feat/retrosynthesis workflow triage). Every part cites a file and key symbols. Companion reading: OpenAI4S source analysis. Prerequisite: knowing “Agent loop = the model speaks → software acts for it → results are fed back” is enough. You do not need to know protein folding first; but you must be willing to start a local daemon and to use SQLite as the ledger.


Part 2

Look at the end first: what a “scientific dual-plane Agent” looks like

Before the first line of code, see what you are assembling. The boundary between a programming IDE Agent and a scientific Agent is not the model. It is these six things:

  1. Actions are not a single tool table—orchestration walks the JSON control plane; computation walks the persistent-kernel scientific plane;
  2. State must survive—a 100 000-row DataFrame stays in kernel memory; context keeps only "<DataFrame 100000×20>";
  3. The completion signal is a contract—conversation/tool turns use the Engine’s own finalize_response; inside a scientific Cell there is only host.submit_output(...);
  4. The ledger precedes the UI—terminal states, tool groups, and usage are written into an append-only Action Ledger; chat bubbles are only a projection;
  5. The core is zero-dependency—scientists’ machines are dirty, firewalls are many, installing deps explodes easily; a stdlib daemon is a product decision;
  6. Deliverables are evidence—versioned Artifacts, environment provenance, retrieval SHA-256, not a chat log that will expire.

Draw those six into an assembly diagram:

flowchart TB
    subgraph UI["① What you can see"]
        CHAT["chat bubbles + Activity cards"]
        NB["read-only Notebook trace"]
        ART["versioned Artifact panel"]
        PERM["permission approval cards"]
    end
    subgraph GATEWAY["② Daemon (stdlib)"]
        HTTP["http.server REST"]
        WS["hand-written WebSocket"]
        RUN["SessionRunner._loop"]
        FIFO["FIFO execution coordinator"]
    end
    subgraph OUTER["③ Outer loop · AgentEngine"]
        MODEL["ModelPort · multi-provider LLM"]
        ROUTE["route_action"]
        TOOLS["NativeToolBatch execute"]
        FINAL["FinalizeAction"]
        CELL["CodeCell python/r"]
        LEDGER["Action Ledger"]
    end
    subgraph INNER["④ Inner loop · Kernel + host RPC"]
        K["Lazy persistent Kernel"]
        SDK["sdk/host.py inject"]
        DISP["HostDispatcher"]
        STORE["SQLite Store"]
    end
    subgraph CONTENT["⑤ Versionable content"]
        SK["skills/*/SKILL.md recipes"]
        ENV["envs/*.yml kernel environments"]
        WSFS["session workspace files"]
    end

    CHAT --> HTTP
    HTTP --> RUN
    RUN --> MODEL
    MODEL --> ROUTE
    ROUTE -->|native JSON| TOOLS
    ROUTE -->|sole finalize| FINAL
    ROUTE -->|one complete Cell| CELL
    TOOLS --> DISP
    CELL --> K
    K <-->|"host_call/ack/response"| SDK
    SDK --> DISP
    DISP --> STORE
    DISP --> SK
    K --> WSFS
    RUN --> LEDGER
    LEDGER --> STORE
    RUN --> WS --> CHAT
    WSFS --> ART
    DISP --> PERM
    K --> NB
    FIFO --> K

Remember this picture. Every step below fills one box.

The key fork from the Open Design route: Open Design does not write the Agent main loop (it treats an already-installed CLI as the engine); OpenAI4S must write the main loop—because scientific execution needs a persistent namespace, synchronous mid-cell Host RPC, and ledger/Artifact contracts; “borrow a coding CLI” cannot give you those.



Part 3

Build the chain of thought: eight propositions you must believe first

Product-manager view: before writing PRD clauses, decide the worldview. OpenAI4S source did not grow from “we need a chat box”; it was worked backwards from the eight propositions below. If you do not believe them first, the next 22 steps will keep reworking.

flowchart TB
    T1["① Scientific state must survive across turns"] --> T2["② print must never pollute the protocol"]
    T2 --> T3["③ A Cell must be able to call Host synchronously mid-cell"]
    T3 --> T4["④ Orchestration and computation cannot contest the same turn"]
    T4 --> T5["⑤ “Done” must be a structured fact"]
    T5 --> T6["⑥ UI bubbles are not the source of truth"]
    T6 --> T7["⑦ Fake provenance / fake green is unacceptable"]
    T7 --> T8["⑧ Core zero-dependency = the trust boundary"]
# Proposition Wrong product if you do not believe it Corresponding module
① DataFrames / fitted objects stay in the kernel Every step shuttles CSV through tools; context explodes; results are not reproducible Step 7 LazyKernel
② Protocol lines and user stdout must be physically split Notebook “occasionally hangs,” frame misalignment, RPC reads a print Steps 7 / 7.5 dup2
③ A scientific loop needs mid-cell host.* Fall back to ReAct; 14 round-trips to do one screen + plot Steps 5–6 Host
④ JSON Tool and Code Cell are mutually exclusive, with priority Same turn both rewrites metadata and runs a simulation; ledger unreadable Step 4 route_action
⑤ finalize ≠ submit_output “The model finished talking” but no metrics / artifact versions Step 9
⑥ Ledger/Store precede bubbles Refresh loses state; Stop semantics drift; share packs are untrustworthy Steps 8, 12
⑦ A wrong fingerprint is worse than none An R artifact stamped with a Python package list; paper attachments lie Steps 7.5, 14
⑧ Core stdlib-only The moment you pull in a framework, sandbox / audit / packaging narratives all blur Steps 1–3, 11

Codex PM check sentence: when reviewing any new requirement ask—“does this add a defensible scientific fact, or just another chat special effect?” Requirements that fail this sentence are deferred by default.

Part 4

Step 0 · PRD: Open AI for Scientist

0.1 One-sentence product

Open AI for Scientist (OpenAI4S): a hybrid scientific-agent workbench that runs on the scientist’s own computer—using a cheap Doubao / Ark API (even the ¥9.9/month tier works), it closes the loop from literature/database retrieval, data analysis, and visualization to a reproducible evidence pack; not another programming IDE Agent, and not multi-tenant SaaS.

0.2 Persona

Persona Daily pain What success looks like
Wet-lab bioinformatician (MSc/postdoc, a little R/Python, mostly at the bench) Sequencing comes back and they cannot clean it; they want a volcano plot but get stuck on deps; they fear losing raw data Drop a count matrix into the workspace, state the goal in Chinese, get a versioned figure + a reproducible notebook trace + an environment fingerprint
Computational chemist (RDKit/docking; GPU locally or on a cluster) Toolchains are scattered; remote GPU scripts are handwritten every time; results cannot be matched to which environment Local orchestration + optional BYOC to GPU; Artifact records generation_id and input lineage

0.3 Jobs-to-be-done

  1. Retrieve and land evidence: query UniProt / RCSB / PubChem / arXiv; results must be hashable and archivable.
  2. Compute in a persistent environment: multi-step pandas/scanpy/RDKit analysis must not collapse because “every step opens a new process.”
  3. Leave an auditable trace: every tool/Cell step enters the ledger; dangerous operations pop an approval card.
  4. Deliver a shareable pack: export a session / evidence pack to collaborators, not a screenshot of chat.
  5. Affordable: default to Volcano Ark ark; the ¥9.9 Small plan can run the main path.

0.4 Non-goals (write them into the PRD or they will not block scope creep)

Explicitly not doing Why
Not a coding IDE Agent (not the home of repo-scale refactor, PR review, multi-file engineering navigation) The user’s work object is data and experiments, not a git monorepo
Not multi-tenant SaaS Data is sensitive; the trust boundary is “local single user + loopback”; multi-tenant would overturn the sandbox and key model
No native Windows kernel The kernel depends on POSIX subprocesses, fd3/fd4, Seatbelt/bwrap; Windows uses WSL2 and the same Linux build
No “unattended auto rm -rf” Headless defaults to deny; scientific users would rather click once more than silently destroy data
MVP does not make Modal/SLURM first-class BYOC starts with SSH/NIM; cluster scheduling is v0.2+

0.5 Success metrics

Metric MVP bar v0.1 target
Cold start to chat After ./setup.sh && ./start.sh, open 127.0.0.1:8760 within 60s Same experience from a one-click dmg
No third-party core deps openai4s engine import graph has no hard deps CI gate
End-to-end scientific turn openai4s run "compute the mean and submit" mockable offline Browser smoke green
Cost reachable Docs write the ark ¥9.9 path clearly UI defaults to guiding ark
Security posture bind 127.0.0.1; headless deny sandbox auto visible degrade
Repro Artifact carries env provenance Export session package

0.6 Constraints

  • Models: prefer Volcano Ark (Doubao etc.); also support OpenAI / Anthropic / Gemini lines; keys never enter the repo.
  • Platforms: macOS (Apple Silicon first-class) + Linux x86_64; Windows = WSL2.
  • Runtime: Python 3.10+; core pure stdlib; the scientific stack goes into the kernel environment, not the control-plane venv.
  • Data root: ~/.openai4s (overridable with OPENAI4S_DATA_DIR).
  • Compliance tone: biosecurity screening on by default; telemetry off by default and requires consent.

0.7 MVP vs v0.1

Capability MVP (can sell the story) v0.1 (can ship to a lab)
Dual-plane routing + finalize/submit ✅ ✅
Persistent Python kernel + host RPC ✅ ✅ + R channel
SQLite Ledger + message projection ✅ ✅ + branch/resume
Static WebUI + WS streaming ✅ ✅ + Timeline/Notebook
Permission cards basic ask/allow persistent rules + restart-safe
Skills 3–5 recipes 30+ built-in
BYOC ❌ Prototype (SSH/NIM)
Packaging pip-installable dmg / linux tarball / WSL zip

0.8 The walls hidden in that one sentence

Our first user story:

"Look up human insulin INS, pull sequence and structure from UniProt/RCSB, draw a simple figure, save the result as a reproducible artifact, and summarize in Chinese."

What the user said The wall it hits Which step fills it
“Look up” Who reasons? How do multiple providers unify? Steps 3–4
(finish in conversation?) What is the completion signal? Steps 4, 9
“Pull sequence and structure” Science APIs + SSRF/egress Steps 9, 18, 19
“Draw / save artifacts” Persistent kernel + Artifact versions Steps 7, 14
(don’t stuff 100k rows into the prompt) Code-as-Action + compaction Steps 7, 15
(dangerous file writes) Permission cards Step 13
(daemon died) Ledger can rebuild history Step 8
“Summarize in Chinese” WebUI streaming bubble projection Steps 11–12
(GPU later) BYOC boundary Step 20
(install for a colleague) Packaging Step 21

0.9 Write the PRD as executable milestones (suggested schedule)

Week Deliverable Corresponding steps
W1 Package layout + Config + Engine unit tests green on a fake LLM transport 1–4
W2 HostDispatcher + in-memory Store stub + openai4s run can finalize 5–6, 9
W3 Real Kernel + host RPC + submit_output end-to-end 6–7
W4 SQLite Ledger + migrations + crash fixture 8
W5 gateway + static WebUI + WS streaming bubbles 11–12
W6 Permission cards + Artifact versions + basic science_search 13–14, 18
W7 Compaction + Delegation + FIFO hardening 15–17
W8 Security-layer default posture + doctor 19
Later BYOC, dmg/pip, R-channel polish 20–21

0.10 Product principles (used to veto requirements while writing code)

  1. The ledger is truth; the UI is a projection. Any PR that “only changes the frontend and calls it done” is sent back.
  2. Control plane and scientific plane do not race. One step walks one channel.
  3. Wrong provenance is worse than missing. If you do not know, write the missing reason.
  4. No subscriber must not default to allow. A security event cannot be released because “nobody is looking at the card.”
  5. Core zero-dependency is a constraint, not a style. Want to add httpx/fastapi? First write why urllib/http.server cannot do it.

✅ This step’s acceptance: you can tell an investor / lab PI in 90 seconds: two planes, stdlib daemon, not an IDE, not SaaS, ¥9.9, macOS+Linux.


Part 5

Step 1 · From PRD to package layout

Purpose

Turn PRD boundaries into directory ownership. Later OpenAI4S crashes are 80% “new capability stuffed into the wrong package”: GPU provider written into skills/, SQL written into gateway.py, the completion contract registered as an ordinary Tool.

Implementation shape

Target tree (against 85e9fa0):

openai4s/
  agent/          # 外环:engine、ports、actions、ledger、compaction、delegation
  kernel/         # 内环:manager、worker、lazy、r_kernel、sandbox 协作
  sdk/            # 注入到 worker 的 host 门面
  host/           # 能力实现(files/llm/delegation/…)
  host_dispatch.py
  llm/            # urllib 多供应商
  tools/          # Native JSON Tool 子类 + registry
  store.py        # SQLite 门面
  storage/        # repositories + migrations
  server/         # gateway、SessionRunner、webui/
  skills_loader/  # 配方加载
  compute/        # BYOC 宿主侧(后期)
  security/       # 分层策略
  config.py prompts.py …
skills/           # 内置配方(数据,不是引擎)
envs/             # conda/micromamba 环境定义
openai4s_compute_provider/  # 远程侧 stdlib worker SDK

Stack

  • Python 3.10+ package layout + pyproject.toml / setuptools
  • Forbid hard third-party imports in the core package (numpy etc. only on the kernel side or behind try/except ImportError)

Definition trade-offs

Do Do not
Split directories by trust boundary and lifecycle Split dogmatically by “layer” (controller/service/repo) until nobody can read it
Keep compatibility façades (gateway.py/store.py/host_dispatch.py) thin Pile new algorithms into the façade
Skills as a data tree shipped with the package Treat Skills as a hot-load arbitrary-code plugin marketplace (MVP)

Strengths and costs

  • Strength: CI and review have an answer to “where does this go”; mypy can be strict only on agent/{engine,ports,…} + host_dispatch.py.
  • Cost: many directories for newcomers; you need bilingual READMEs as gates (OpenAI4S does exactly that).

How it couples to previous and later modules

  • Before: PRD non-goals directly decide “there is no ide/, no saas/.”
  • After: step 2 config, step 4 engine, step 11 server all hang on this tree.
flowchart LR
    PRD["PRD boundaries"] --> PKG["package ownership table"]
    PKG --> AGENT["agent outer loop"]
    PKG --> KER["kernel inner loop"]
    PKG --> TOOLS["tools control plane"]
    PKG --> SRV["server projection"]
    PKG --> DATA["skills/envs data"]

1.4 Week-one minimum runnable skeleton (suggested files to create now)

pyproject.toml
openai4s/__init__.py
openai4s/__main__.py
openai4s/config.py
openai4s/agent/{__init__,ports,models,events,actions,engine}.py
openai4s/llm/{__init__,client,transport,registry}.py
openai4s/tools/{__init__,base,registry}.py
openai4s/host_dispatch.py
openai4s/sdk/host.py
openai4s/kernel/{manager,worker,lazy}.py
openai4s/store.py
openai4s/server/{gateway.py,webui/index.html,webui/app.js,webui/style.css}
tests/test_agent_route_action.py

First let python -m openai4s print a version; then let AgentEngine run finalize under FakeModel. Do not start by beautifying the WebUI.

1.5 Ownership-table example (pin it on the wall)

Change Correct place Wrong place
New control tool tools/foo.py + TOOL_TYPES inlined in gateway.py
New Host capability algorithm host/foo.py pile logic in host_dispatch.py
New SQL storage/foo.py + migration copy SQL in store.py
New WS event projection server/*_service.py only change app.js and pretend the backend exists
New scientific flow skills/my-flow/SKILL.md hardcode 3 000 lines into the system prompt
New GPU backend compute/ + remote-compute-* shell out from some random skill

✅ This step’s acceptance: draw a table of where “new Tool / new Host capability / new SQL / new UI route” land; pick one wrong landing and explain the consequence.


Part 6

Step 2 · Config and data-directory contract

Purpose

Before writing the LLM, decide: where keys come from, where data is written, which port the process binds. Scientific users will configure models from the UI and also use .env; the two must not step on each other.

Implementation shape

  • openai4s/config.py: Config / LLMConfig dataclass + zero-dep .env loading
  • Env-var layering: OPENAI4S_<PROVIDER>_API_KEY → OPENAI4S_LLM_API_KEY → provider default
  • Data root: ~/.openai4s/{logs,artifacts,tool-results,compaction-history,openai4s.db,agent-workspaces,…}
  • Port: OPENAI4S_HOST=127.0.0.1, OPENAI4S_PORT=8760

Stack

  • dataclasses + os.environ + a hand-written dotenv (do not pull in python-dotenv)

Definition trade-offs

Do Do not
Treat placeholder keys (your-api-key-here) as unconfigured Treat a sample key as configured and get a “fake login”
Daemon can start with no key; configure later in the UI Force interactive input at start (breaks start.sh)
Data dir 0700, database 0600 A world-readable “convenient share directory”

Strengths and costs

  • Strength: Ark / Claude / Gemini keys can coexist; tests can point OPENAI4S_DATA_DIR at tmp.
  • Cost: when debugging layered resolution you must print “which layer actually hit.”

How it couples to previous and later modules

  • After: LLM client (step 3) and Store paths (step 8); Gateway reads the same Config.
flowchart TD
    ENV["real environment variables"] --> RES["LLMConfig resolve"]
    DOT[".env does not override existing"] --> RES
    RES --> KEY["api_key / base_url / model"]
    RES --> CFG["Config.data_dir / host / port"]
    CFG --> DB["db_path"]
    CFG --> WS["agent-workspaces"]

✅ This step’s acceptance: with only OPENAI4S_ARK_API_KEY set, provider=ark can read it; a template placeholder is judged as no key.


Part 7

Step 3 · Multi-provider LLM: the `urllib` client

Purpose

A scientist’s key may be Ark, DeepSeek, or official Claude/GPT/Gemini. The product must switch providers in one line, but cannot pull a dependency for every SDK—the core must stay pure stdlib.

Implementation shape

openai4s/llm/
  client.py          # chat() 编排入口
  transport.py       # urllib 的 post_json / post_sse
  registry.py        # PROVIDERS 目录(wire/model/base_url)
  providers/
    openai.py        # Chat Completions
    responses.py     # OpenAI Responses
    anthropic.py     # Messages
    gemini.py        # generateContent
  tooling.py messages.py capabilities.py catalog.py

Outward compatibility façade: from openai4s.llm import chat.

Stack

  • urllib.request + SSE line parsing
  • Unified normalization: ModelReply / tool_calls (keep raw_arguments + parse_error + provider_meta)

Definition trade-offs

Do Do not
Four wires + ark as an OpenAI-compatible entry Install an official SDK for every model vendor
Capability layer (vision / tool_calling / parallel) Assume every endpoint supports tools
Fall back to the Code-as-Action path when there is no tool_calling Hard-send a schema and fail the whole turn
Streaming on_delta Put a complex multimodal editor on the MVP critical path

Strengths and costs

  • Strength: small package; offline tests can mock the whole transport; the Ark ¥9.9 path has zero friction.
  • Cost: SSE/error codes must be aligned to each vendor’s docs yourself; strict schema differences need a capability buffer.

How it couples to previous and later modules

  • Before: Config resolves the key.
  • After: AgentEngine’s ModelPort wraps a ChatModel; Host’s host.llm also walks the same chat().
sequenceDiagram
    participant E as AgentEngine
    participant C as llm.chat
    participant T as transport.urllib
    participant P as Provider API
    E->>C: messages + tools + on_delta
    C->>C: provider_spec + capabilities
    C->>T: post_sse / post_json
    T->>P: HTTPS
    P-->>T: SSE chunks
    T-->>C: deltas + final
    C-->>E: ModelReply(tool_calls|content)

3.4 Minimal chat() shape (the contract you freeze first)

# 教学简化版 —— 生产代码见 openai4s/llm/client.py
def chat(messages, cfg, *, on_delta=None, tools=None, post_sse, post_json):
    spec = provider_spec(cfg.provider)
    wire = spec["wire"]          # openai | responses | anthropic | gemini
    model = cfg.model or spec["model"]
    base = cfg.base_url or spec["base_url"]
    # 1) capability 门闩  2) 译 tool specs  3) 调对应 wire
    return wire_dispatch[wire](...)

The return value must be ingestible by ModelReply.from_mapping, at least including:

  • content: str
  • tool_calls: list[{id, wire_id, name, ordinal, raw_arguments, arguments, parse_error, provider_meta}]
  • usage: {prompt_tokens, completion_tokens}

Trade-off: keep raw_arguments and parse_error together—the routing layer is a lossless boundary, not a second JSON parser. Parse failures still enter the Ledger; they must not be silently dropped.

3.5 Why Ark walks the OpenAI-compatible wire

Volcano Ark Agent Plan exposes an OpenAI Chat compatible endpoint. You put provider=ark in the registry, reuse providers/openai.py, and only change the default base_url/model catalog. That way the ¥9.9 path needs no third protocol implementation.

✅ This step’s acceptance: against a fake transport, assert that ark and anthropic both produce the same shape of tool_calls; when there is no key, the error message points at the correct env-var name.


Part 8

Step 4 · `AgentEngine` ports and `route_action`

Purpose

Write an outer-loop state machine that is unit-testable and does not depend on kernel/server. This is the spine of the whole product: CLI openai4s run and Web SessionRunner._loop must share it, or the two loops will drift.

Implementation shape

  • agent/ports.py: ModelPort / ActionExecutor / ContextPolicy / EventSink / CancellationPort / CompletionPort / ReplyInterceptor
  • agent/engine.py: AgentEngine.run turn loop
  • agent/actions.py: route_action, CodeCell, NativeToolBatch, FinalizeAction
  • agent/models.py: RunState / ModelReply / ExecutionOutcome / EngineResult
  • agent/events.py: RunStarted…RunFinished

The core loop (semantically compressed):

while turn < max_turns:
  prepare context
  reply = model.complete(messages, on_delta)
  action = route_action(reply.content, reply.tool_calls)
  outcome = executor.execute(action, reply, state)
  append history
  if outcome.stop_reason or completion: break

route_action iron laws:

  1. The sole native call and it is named finalize_response → FinalizeAction
  2. Otherwise, if there is any native call → NativeToolBatch (whole batch; control plane first)
  3. Otherwise extract the first complete python /r Cell from the body
  4. None of the above → None (upper layer nudges)

Stack

  • Pure typing Protocol + dataclass; mypy strict on these files

Definition trade-offs

Do Do not
At most one action channel per step Parallel “tools + Cell” in the same turn
finalize does not enter the Tool registry Let a plugin replace the completion contract
Ordinary prose ≠ done Treat the last assistant text as done
Multiple Cells: run only the first and attach MULTI_CELL_NOTE Silently execute every Cell

Strengths and costs

  • Strength: the engine can test routing and terminal states with no network and no subprocess; Web/CLI only swap adapters.
  • Cost: if the model “talks and writes two Cells,” they are trimmed; you need the prompt and nudges to teach it.

How it couples to previous and later modules

  • Before: LLM ModelReply.
  • After: the Executor is assembled by agent/runtime.py on CLI and by WebActionExecutor on Web; real work goes into Host/Kernel.
flowchart TD
    R["ModelReply"] --> RA{"route_action"}
    RA -->|"sole finalize_response"| F["FinalizeAction"]
    RA -->|"other native calls"| B["NativeToolBatch"]
    RA -->|"no native"| X{"extract_action"}
    X -->|python/r fence| C["CodeCell"]
    X -->|none| N["None → nudge"]
    F --> EX["ActionExecutor"]
    B --> EX
    C --> EX
    N --> EX

4.4 FakeModel-driven first tests (suggested to land as written)

class FakeModel:
    def __init__(self, replies):
        self.replies = list(replies)
    def complete(self, messages, on_delta):
        reply = self.replies.pop(0)
        if on_delta and reply.get("content"):
            on_delta(reply["content"])
        return reply

class FakeExecutor:
    def execute(self, action, reply, state):
        # 断言 action 类型,然后返回 ExecutionOutcome(stop_reason=...)
        ...

Test first:

  1. Sole finalize_response → stop reason is completion;
  2. web_search + ```python in the body → walk the batch, do not run code;
  3. Two python Cells in the body → extract only the first;
  4. Unclosed ```python → do not execute + nudge.

Wait until these four are green before wiring real HTTP.

4.5 Micro-timing of one turn inside the engine

sequenceDiagram
    participant P as ContextPolicy
    participant M as ModelPort
    participant R as route_action
    participant X as ActionExecutor
    participant S as EventSink
    P->>P: prepare(messages)
    M->>S: TextDelta*
    M->>R: ModelReply
    R->>X: Action|None
    X->>S: OutcomeProduced
    alt stop/completion
      X-->>P: EngineResult
    else continue
      X->>P: history appended, next turn
    end

✅ This step’s acceptance: pure unit tests cover: finalize first, tools beat code, only the first of multiple Cells, unclosed fences do not execute.


Part 9

Step 5 · `HostDispatcher`: the capability envelope

Purpose

Wherever a call comes from—a native Tool, or host.web_search inside a Cell—it must pass through the same permission / approval / audit / injection-screen / activity-event envelope. Otherwise you will own two inconsistent security stories.

Implementation shape

  • openai4s/host_dispatch.py: HostDispatcher.__call__(method, args) -> data
  • openai4s/host/*.py: WorkspaceFileService, LLMService, DelegationService, CompletionService, SkillService…
  • soft-fail: {"error": msg} → worker turns it into RuntimeError

Stack

  • Thread lock + optional ThreadPoolExecutor (read-only tool parallel waves live on the control executor side)
  • Collaborates with tools.registry.get_tool_by_host_method

Definition trade-offs

Do Do not
Dispatcher as envelope; algorithms sink into host/ A 5 000-line God class implementing all scientific logic
Visible methods project as Activity steps Flush internal host.llm calls into the Timeline
Plugin register_tool generic resolution Hand-write a _m_* branch for every new tool (thin adapters may stay for history)

Strengths and costs

  • Strength: one point for audit and permission; Web control tools and kernel RPC share policy.
  • Cost: the façade is still large; refactors must be “surgery”; forbid whole-file rewrites.

How it couples to previous and later modules

  • After: the SDK only sends host_call; the Kernel manager calls the Dispatcher; the Ledger records audit.
flowchart LR
    A["Tool.invoke / host.*"] --> D["HostDispatcher"]
    D --> P["permission / approval"]
    D --> I["injection screen"]
    D --> S["host service"]
    D --> L["audit / step event"]
    S --> OUT["data | {error}"]

✅ This step’s acceptance: the same web_fetch entering from a Tool and from host.web_fetch has matching approval and audit fields.


Part 10

Step 6 · Host SDK injected into the kernel

Purpose

Let scientific code call host capabilities synchronously mid-Cell: host.llm, host.delegate, host.save_artifact… This is the inner loop that a tool_use architecture does not have.

Implementation shape

  • openai4s/sdk/host.py: in-worker host singleton façade
  • Inject a host_call(method, args) callback when starting the worker
  • Codec: SDK snake_case ↔ wire camelCase; omit None fields (avoid JSON null hitting strict validation)
  • host.bash: does not execute shell on the Host; Host only issues a one-shot token bound to command hash / cwd / generation / challenge; the worker executes locally

Stack

  • Pure Python façade; no third party
  • HOST_CAPABILITY_VERSION written into the bootstrap manifest for restore comparison

Definition trade-offs

Do Do not
Python inner-loop RPC Give R equivalent mid-cell Host RPC (R is an independent analysis channel)
Strip delegate/compute/... symbols from the analysis kernel Runtime if-reject (a symbol that exists but throws a business error is more misleading)
host.submit_output as the sole in-Cell completion Register submit_output as a JSON Tool

Strengths and costs

  • Strength: scientists compose capabilities with for/if/libraries; large objects never enter the prompt.
  • Cost: deadlock risk—must have a single in-flight host_call lock + a single reader loop.

How it couples to previous and later modules

  • Before: Dispatcher.
  • After: Kernel protocol frames host_call / host_ack / host_response.
sequenceDiagram
    participant Cell as Python Cell
    participant SDK as host SDK
    participant W as worker.py
    participant M as Kernel manager
    participant H as HostDispatcher
    Cell->>SDK: host.web_search(...)
    SDK->>W: host_call frame
    W->>M: stdout protocol line
    M->>H: dispatch(method,args)
    H-->>M: data
    M->>W: host_response
    W-->>SDK: return
    SDK-->>Cell: result

✅ This step’s acceptance: in a test kernel, execute host.submit_output({"ok": True}, completion_bullets=["done"]), outer-loop stop_reason=submitted.


Part 11

Step 7 · Lazy persistent kernel + JSON-line protocol + sandbox

Purpose

Land the scientific plane: Tool/Finalize-only turns do not start a worker; the first Cell lazily pulls one up; the namespace persists across Cells; stdout and protocol are split; an OS sandbox constrains write paths and the naked network.

Implementation shape

  • kernel/manager.py: Kernel on the host side, spawn + single-thread frame reader
  • kernel/worker.py: Python worker; compile(..., "<kernel:N>") + linecache; getrusage accounting
  • kernel/lazy.py: CLI one-shot lazy ownership
  • kernel/r_kernel.py + r_worker.R: same protocol; protocol on fd3/fd4, noise to stderr
  • kernel/environment.py: subprocess env rebuilt from an allowlist (does not inherit daemon keys)
  • security/sandbox.py: macOS Seatbelt / Linux bubblewrap; auto|enforce|off

Protocol (concept):

{"type":"execute","id":"...","code":"..."}
{"type":"host_call","id":"...","method":"web_search","args":[...]}
{"type":"host_ack","id":"..."}
{"type":"host_response","id":"...","data":{...}}
{"type":"result","id":"...","ok":true,"stdout":"..."}

Stack

  • subprocess + JSON lines
  • POSIX redirect (R); Seatbelt / bwrap

Definition trade-offs

Do Do not
Lazy start Preheat two heavy kernels at session start (waste)
Persistent namespace A new process per Cell (ReAct-style; kills the scientific experience)
Capture stdout separately from the protocol Let print break frame sync
Native Windows kernel Refuse outright and guide to WSL2

Strengths and costs

  • Strength: Code-as-Action collapses 14 tool round-trips into 1 Cell; in-memory state is reusable.
  • Cost: crash recovery, generation ABA, and watchdog all have to be done seriously (see step 17).

How it couples to previous and later modules

  • Artifact provenance binds to kernel generation, not the daemon process’s package list.
  • The security layer (step 19) stacks on the spawn boundary.
flowchart TB
    T["Tool/Finalize turn"] -->|do not spawn| Z["no worker"]
    C["first Python Cell"] --> L["Lazy start python slot"]
    R["first R Cell"] --> LR["Lazy start R slot"]
    L --> SB["sandbox wrap"]
    SB --> W["worker subprocess"]
    W --> NS["persistent globals"]
    W --> RPC["mid-cell host RPC"]

7.4 Three iron laws when implementing the protocol

  1. Single reader: only the Kernel manager thread reads worker stdout; supervisor/watchdog must not sneak a read.
  2. Single in-flight host_call: the worker holds a lock through host_call → host_response; concurrent RPC will interleave frames.
  3. generation is monotonic: every respawn bumps generation; tokens/leases/approvals bound to the old generation must fail.

7.5 Minimal worker pseudocode

# worker.py 概念版
for line in sys.stdin:
    msg = json.loads(line)
    if msg["type"] == "execute":
        # 重定向 stdout 到捕获缓冲,协议帧走另一条通道
        try:
            exec(compile(msg["code"], f"<kernel:{n}>", "exec"), ns)
            send({"type": "result", "id": msg["id"], "ok": True, "stdout": buf})
        except Exception as e:
            send({"type": "result", "id": msg["id"], "ok": False, "error": ...})

The Host SDK’s host_call must walk a channel separated from stdout capture, or print and RPC smash each other.

✅ This step’s acceptance: two consecutive Cells, the second can read the first’s variables; a pure finalize turn has no worker in the process list.

⚠️ Not done yet: protocol anti-pollution and provenance taste are in step 7.5; that is where this tutorial parts ways with “ordinary Agent tutorials.”



Part 12

Step 7.5 · Kernel-taste lab: dup2 / frame cap / false truth

Purpose

Step 7 made the kernel “able to run.” This step makes you understand why the author wrote the worker that way—this is the physical landing of OpenAI4S taste. Against source: kernel/worker.py file header, manager.py single reader, CLAUDE.md “wrong provenance is worse than none.”

Lab A · Protocol split (required thought experiment)

Suppose you are lazy: protocol and print share fd1.

  1. The model Cell does print(huge_df);
  2. At the same time host.web_search(...) sends host_call;
  3. The manager’s readline() reads a half table → JSON dies → the whole session hangs.

OpenAI4S’s solution:

  • Python: dup2(2,1) + a high protocol fd published to sys._openai4s_protocol_* (resolve again on every call; caching forbidden);
  • R: protocol on fd3/fd4, noise to stderr.

Trade-off: the protocol implementation becomes “ugly”; product behavior becomes “dull-correct.”

Lab B · The frame cap is not a guess

Read the _MAX_FRAME_BYTES comment chain in worker.py:

  • First there was a character cap MAX_OUTPUT;
  • Then it was found that UTF-8 / \uXXXX / emoji surrogate pairs make “bytes” far larger than “characters”;
  • The constant was revised from a wrong 6 to 12 bytes/character worst case, and tests nail it down.

Taste lesson: a scientific Agent’s users will type Chinese, molecular formulas, emoji comments. The cap must be derived from the worst character, or you will lose error_lineno and usage while “looking like you obeyed the docs”—again “wrong is worse than none.”

Lab C · Single in-flight RPC and generation

flowchart TB
    A["Cell calls host.llm"] --> L["acquire _HOST_CALL_LOCK"]
    L --> S["send host_call"]
    S --> W["wait for host_response matching id"]
    W --> U["release lock"]
    U --> N["Cell continues"]
    R["worker respawn"] --> G["generation++"]
    G --> X["all old bash one-shot tokens fail"]

Acceptance thinking: if you allow concurrent host_call without a lock, what you save is latency; what you buy is occasional mis-bound results—unacceptable in a research scene.

Lab D · provenance binds to generation

Wrong implementation: capture_environment() freezes the daemon’s pip freeze. Correct implementation: look up “the generation_id of the language slot that wrote this Artifact” and freeze that interpreter’s package set; if unreadable, record the missing reason.

Product copy: writing “environment unknown (reason)” in the UI is far better than writing “environment = a list of packages that do not belong to it.”

Definition trade-offs (this step’s dedicated table)

Do Do not
Protocol anti-pollution, anti-huge-frame, anti-fd-drift Share stdout for “implementation simplicity”
Comments that write the failure history and the derivation Silent magic constants
generation-bound safety tokens Keep using old tokens after restart
matplotlib figures captured by the gateway autoclose in the worker so the figure vanishes

How it couples to previous and later modules

  • Steps 5–6 Host’s soft-fail contract depends on this step turning {"error"} into an in-Cell exception.
  • Step 14 Artifact depends on this step’s generation fingerprint.
  • Step 19 sandbox stacks outside spawn; it cannot replace the protocol split.

✅ This step’s acceptance: you can explain “why R uses fd3/fd4”; you can explain “why the frame cap is 12”; you can explain “why an R artifact must not be stamped with a Python freeze.” If you cannot, you have not yet understood the kernel.


Part 13

Step 8 · Action Ledger + SQLite Store

Purpose

The ledger precedes the UI: tool declarations and results, Cell attempts, terminal facts, usage, approval resolutions must all be reconstructable. After a daemon restart, you cannot rely on “what the browser still remembers.”

Implementation shape

  • store.py: single connection, RLock, schema/migrations, read-only query guardrails, compatibility façade
  • storage/: frame / artifact / action ledger / approvals / settings / memories… repositories
  • agent/ledger.py: write AgentEvent as immutable group/event; a reducer fills in half-open tool groups after a crash

Key invariants:

  • A native assistant declaration + all of its tool results is an atomic group (compaction cannot split it either)
  • Terminal state is appended; it is not reverse-inferred from chat bubbles
  • host.query is read-only and denylists settings/permissions/ledger and other sensitive tables
  • PRAGMA user_version + schema_migrations; migrations in a single transaction, not WAL (an audit-store choice)

Stack

  • stdlib sqlite3
  • Explicit migration map Store._migrate

Definition trade-offs

Do Do not
append-only semantic groups Impersonate history with a mutable “current-state row”
After restart, approval cards can be clicked, but old args are not replayed Replay the approval payload as execution args
Store.close is idempotent and only evicts itself A global singleton deadlocks the next generation of connections

Strengths and costs

  • Strength: Timeline, cost, and compliance audit share one source; tests can assert the trace.
  • Cost: you must maintain both the projection (messages) and the truth (ledger); it is mentally expensive.

How it couples to previous and later modules

  • Engine events → Ledger writer; Gateway projects assistant-visible text; restore reads the journal.
flowchart TB
    E["AgentEvent"] --> W["RuntimeActionLedger"]
    W --> G["action_groups"]
    W --> V["action_events"]
    G --> R["reducer → provider-safe history"]
    R --> M["next model.complete"]
    UI["chat bubbles"] -.->|projection only| G

8.4 Migration discipline (copy into team convention)

  • New version = new step function + SCHEMA_VERSION += 1
  • A step must not commit itself; the outer layer is a single transaction
  • integrity_check first, then backup API, then migrate; on failure keep the backup
  • Backfills must be WHERE-idempotent
  • Do not casually change journal_mode/synchronous: this is an audit store, not a high-QPS business store

8.5 host.query denylist mental model

The Agent can “look at its own database”—that is powerful and dangerous. The denylist at least covers: settings, permission tables, the raw ledger, recovery journal, credentials-related. Match after stripping literals so SELECT 'settings' is not a false positive, but the "settings" identifier is still blocked.

✅ This step’s acceptance: manufacture a crash fixture of “tool group missing results”; after restart the reducer fills a canonical error, rather than sending a half-open batch back to the model.


Part 14

Step 9 · Native Tool catalog and `finalize_response`

Purpose

Make deterministic orchestration a named Tool subclass catalog: schema, safety policy, and execute() in the same file; and close non-scientific turns with the Engine’s own finalize_response.

Implementation shape

  • tools/base.py: Tool / ToolSpec / invoke() (forbid calling execute directly and bypassing the envelope)
  • tools/registry.py: TOOL_TYPES the unique built-in instantiation point; forbid registering bash / submit_output
  • agent/finalize.py: closed schema (summary/findings/metrics/artifacts/limitations/next_steps/completion_bullets)
  • Scientific-library breadth does not inflate tool count: only science_list_dbs + science_search

Stack

  • JSON Schema subset validation (in-house / built-in)
  • provider adapters translate ToolSpec into each vendor’s wire

Definition trade-offs

Do Do not
Read-only tools can run in parallel waves by resource_keys Parallelize mutating tools (after the barrier they go serial)
writes_files=True does Artifact transactions at the Web boundary Double-register kernel file writes in the Dispatcher
finalize metadata ToolSpec is only for model visibility Put finalize into REGISTRY so a plugin can replace it

Strengths and costs

  • Strength: classification is clear; permission metadata and behavior do not drift.
  • Cost: the catalog will grow; you need taxonomy/side_effect_class to keep it.

How it couples to previous and later modules

  • route_action recognizes finalize; WebActionExecutor / CLI runtime execute the batch; results enter the Ledger.
flowchart LR
    SPEC["ToolSpec catalog"] --> LLM["provider tools params"]
    LLM --> CALLS["native tool_calls"]
    CALLS --> RA["route_action"]
    RA -->|batch| INV["Tool.invoke → Dispatcher"]
    RA -->|finalize| FIN["finalize.py validate → CompletionRecord"]

9.4 Control-tool parallel waves (do not copy this wrong when implementing)

A prefix that is read-only and whose resource_keys do not conflict can run in parallel; the first mutating/unknown call is a barrier, then serial; write-back to history must follow the provider’s original order. Parallel completion order must never scramble the canonical group—otherwise replay and compaction all break.

9.5 finalize_response minimal-schema mental model

The model must give:

  • summary (grounded)
  • completion_bullets (1–4 phrases of completed actions)
  • optional findings/metrics/artifacts/limitations/next_steps

The Host then validates the same closed schema again. A scientific Cell must not close with it; keep using host.submit_output.

✅ This step’s acceptance: attempting register_tool(bash) fails; a sole finalize_response produces a structured completion; when other tools are mixed in, it does not walk FinalizeAction.


Part 15

Step 10 · Skills: code recipes, not JSON Schema

Purpose

Write domain knowledge (docking, single-cell, folding flows) as loadable recipes: SKILL.md + optional kernel.py sidecar. The model first sees name+summary; when needed it host.search_skills / load_skill—progressive disclosure.

Implementation shape

  • Repo skills/<name>/SKILL.md
  • openai4s/skills_loader/loader.py
  • User skills only under <data_dir>/user-skills; on a bundled same-name conflict, bundled (read-only) wins
  • sidecar compile-check before use
  • Host authoring flow: draft → personal; Web Customize is user origin and cannot forge a trusted origin

Stack

  • Markdown frontmatter + file tree
  • Generation-bound to Store capability state (the loader does not cache a closed repo)

Definition trade-offs

Do Do not
Skill = code recipe / operations manual Skill = another JSON tool schema marketplace
Progressive disclosure Stuff 34 full texts into the system prompt
sidecar structural gates import arbitrary user code with no check

Strengths and costs

  • Strength: scientific flows are versionable and reviewable; the engine stays thin.
  • Cost: recipe quality variance becomes product experience; you need a readiness check.

How it couples to previous and later modules

  • Tools: search_skills / load_skill; in-Cell host.skills.*; remote compute skills carry provider.py (step 20).
flowchart TD
    CAT["catalog: name+summary"] --> M["model decides whether to load"]
    M -->|load| MD["SKILL.md full text"]
    M -->|sidecar| PY["kernel.py compile-check"]
    MD --> CELL["Cell executes by recipe"]
    PY --> CELL

10.4 Write your first Skill (minimal recipe)

skills/insulin-quicklook/
  SKILL.md

Suggested SKILL.md structure:

---
name: insulin-quicklook
summary: 从 UniProt 拉取 INS 摘要并在内核中计算序列长度
---

# Insulin quicklook

1. 用 science_search 查 UniProt: INS human
2. 将序列写入 workspace
3. 开一个 python cell 计算 len(seq) 并作图
4. host.save_artifact(...)
5. host.submit_output(...)

Do not write the recipe as a “mysterious JSON tool.” The recipe’s readers are the model and a human reviewer.

✅ This step’s acceptance: before load, the system prompt does not contain the full text; after load, a Cell can import the sidecar; a user skill cannot shadow the built-in catalog.


Part 16

Step 11 · Gateway: `http.server` + hand-written WebSocket

Purpose

Productize the engine into a local workbench: REST for resources, WS for turn streaming events. Stay zero-framework—one fewer dependency conflict in a scientist’s environment.

Implementation shape

  • server/gateway.py: composition adapter (large, but a façade)
  • WSHub: subscribe by root_frame_id, broadcast, disconnect resume cursor
  • SessionRunner: session state, job queue, run_message, _loop
  • server/webui/: index.html / app.js / style.css no build step, served as static files from the worktree
  • Default bind 127.0.0.1; local auth / security headers (CSP etc.)

Stack

  • http.server + hand-written WS handshake/frames (see ws_frames.py)
  • Thread model: blocking POST runs a turn + WS push

Definition trade-offs

Do Do not
stdlib HTTP/WS FastAPI/uvicorn as a core hard dep
Static worktree WebUI A React build chain on the MVP critical path
Singleton pidfile daemon Multiple daemons racing for the same DB

Strengths and costs

  • Strength: ./start.sh is simple; JS changes take effect on refresh.
  • Cost: the gateway file is huge; new routes must land in a focused service, not keep swelling it.

How it couples to previous and later modules

  • The next chapter is dedicated to the send path; Artifact/permission/restore services hang on the same runner.
flowchart LR
    B["Browser"] -->|REST| G["gateway.py"]
    B -->|WS| H["WSHub"]
    G --> SR["SessionRunner"]
    SR --> ENG["AgentEngine"]
    SR --> H
    G --> STATIC["webui/*"]

✅ This step’s acceptance: after openai4s serve, GET / is 200; WS upgrade succeeds; a second instance is refused because of the pidfile.


Part 17

Step 12 · WebUI send path: composer → bubble

Purpose

Open the full loop a user can perceive: input box → POST message → outer-loop multi-turn → WS events → assistant bubble / Activity / Artifact. This is the main path for accepting the product, and a preview of the tutorial’s most important sequence diagram.

Implementation shape (against source)

  1. composer: send(text, opts) in server/webui/app.js
  2. If there is no session: POST /frames creates one, and sub(frameId) subscribes to WS
  3. Optimistically render the user bubble; if S.running then mark queued (server FIFO accepts)
  4. sub(currentId) first, then POST—otherwise the first text_reset / text_chunk is dropped for lack of a subscriber
  5. POST /frames/{id}/message (singular; wait:false) → Gateway returns 202 + {job_id, execution_id, request_id}; the turn runs on a background thread and does not block HTTP
  6. SessionRunner.submit_message → FIFO ticket + MessageJob → run_message
  7. SessionRunner._loop assembles AgentEngine + ChatModel + WebActionExecutor + CompactionPolicy + WebEventSink
  8. During engine turns: TextDelta / tool / cell events → emit → WSHub.broadcast (each stamped with a monotonic seq)
  9. The frontend stitches chunks into the assistant bubble; tools become Activity cards; file writes become Artifacts
  10. Completion: server/completions.py projects structured output / bullets / artifact delta as the final assistant message, then sends a terminal frame_update; S.running=false, composer unlocks

WS resume contract (must be written into the product): connectWS() connects to /api/v1/ws; view_session carries since_seq (the largest seq this tab has applied) and epoch (daemon process identity). After a daemon restart epoch changes → replay_begin.gap=true → the UI must REST re-pull history; it must not pretend “you have caught up.” The buffer only covers the currently in-progress turn, not a full event log.

Stack

  • fetch REST + WebSocket client
  • Server-side threading + Event cancellation
  • Hand-written WS: ws_frames.py; resume via seq + epoch, not Socket.IO

Definition trade-offs

Do Do not
wait:false + 202 immediate return Synchronously block until the whole Agent round ends (will freeze the browser)
Input again while sending → queue Silently drop (old bug)
Client generates the annotation admission id Rely only on a server id (unrecoverable after disconnect)
Plan mode rewrites the payload, forbids tools Plan mode still allows writing files at will
/skillname becomes a hard instruction Hope the model remembers to load_skill itself
since_seq + epoch resume Treat the UI transcript as the only truth

Strengths and costs

  • Strength: the “chat” the user understands is decoupled from the real ledger; refresh can re-pull messages; 202 makes Stop / queue UX possible.
  • Cost: subscribe timing, admission, queue hints, epoch gaps are all detail minefields.

How it couples to previous and later modules

  • Full module-level replay is at the 🎬 at the end of this document; permission-card and Artifact events insert on the same WS stream.
sequenceDiagram
    autonumber
    participant U as user
    participant UI as app.js composer
    participant REST as gateway POST
    participant SR as SessionRunner
    participant ENG as AgentEngine
    participant K as Kernel/Tools
    participant WS as WSHub
    participant B as assistant bubble

    U->>UI: type a prompt and send
    UI->>UI: optimistic user bubble; openTurnTicket
    UI->>WS: view_session(fid, since_seq, epoch)
    UI->>REST: POST /frames/{id}/message wait:false
    REST-->>UI: 202 execution_id
    REST->>SR: submit_message → FIFO → run_message
    SR->>WS: text_reset
    SR->>ENG: _loop → Engine.run
    loop each turn
      ENG->>ENG: model.complete (SSE deltas)
      ENG-->>WS: text_chunk / notebook_cell / activity seq++
      WS-->>B: stream render
      ENG->>K: Tool batch or Cell
      K-->>ENG: observation
    end
    ENG-->>SR: submitted / finalize / max_turns
    SR-->>WS: completions projection + frame_update terminal
    WS-->>B: completed state
    B-->>U: readable reply + Artifact entry

12.4 Four timing things the frontend must get right

  1. Generate and remember the admission id before POST (comment/pin scenes); a server-issued id does not help after disconnect.
  2. sub(frameId) before POST; the first-packet delta must not be dropped.
  3. Use the S.running snapshot at dispatch time to decide whether to openTurnTicket—there is an await in the middle; an old snapshot expires.
  4. Do not let the header model overwrite a session-pinned revision (the source comments record this pit).
  5. Reconnect with since_seq + epoch; an epoch change must REST re-pull; do not blindly trust the buffer.

12.5 Server run_message responsibility list

  • Validate frame/project scope
  • Write the user message (including annotation consumption)
  • If busy, enqueue; if idle, start a thread (wait:false path)
  • Inside the thread: take a FIFO ticket → _loop → completions project the completion message → release the ticket → pull the next queued item
  • Any exception: write a terminal failure (do not double-write for the same job)

✅ This step’s acceptance: run one sentence by hand in the browser; Network shows POST .../message returning 202; WS has text_reset/text_chunk; after refresh history is still there; sending again while running queues rather than disappearing; after killing the daemon and reconnecting, an epoch change triggers a history re-pull.


Part 18

Step 13 · Permission and approval cards

Purpose

A scientific Agent will write files, go to the network, run a shell. The product must turn risky actions into clickable approval cards, and: no browser subscriber ≠ auto-allow; headless defaults to deny.

Implementation shape

  • permissions.py + permission rules/requests in the Store
  • HostDispatcher asks before a risky tool
  • WebUI renders the approval card; the resolution is written back to SQLite
  • Semantics:
  • Process still alive: wake the same blocked call
  • Daemon already restarted: record permission_resolution (the old operation was not executed), require Continue/replan
  • once: exact match of conversation/tool/target within 15 minutes, atomic consume
  • OPENAI4S_UNATTENDED_APPROVAL=deny|allow

Stack

  • Persistent request rows + condition-variable/event wake
  • Frontend WS event permission_request

Definition trade-offs

Do Do not
Approvals survive across restart Replay a redacted payload as execution args
Session/project/global standing rules A complex IAM role system on MVP
Notebook REPL off by default Default-open an arbitrary kernel execution entry

Strengths and costs

  • Strength: a security narrative a lab can accept; audit is complete.
  • Cost: after restart, “clicked allow but it did not execute” needs UI copy to educate, or users think it is a bug.

How it couples to previous and later modules

  • Tool classes declare a permission target; the bash capability is bound to generation at another layer.
sequenceDiagram
    participant T as Tool/host call
    participant D as Dispatcher
    participant DB as SQLite
    participant UI as approval card
    T->>D: risky action
    D->>DB: write permission_request
    D-->>UI: WS push card
    UI->>DB: allow once/session/...
    alt daemon not restarted
      DB-->>D: wake the original call
      D-->>T: continue execute
    else daemon already restarted
      DB-->>UI: requires_continue
      UI->>T: user explicitly Continue/replan
    end

✅ This step’s acceptance: under headless deny, a risky tool fails; with a UI the card is clickable; after restart, once does not re-ignite old args.


Part 19

Step 14 · Artifact versioning and provenance

Purpose

What a scientist wants is not “the model said it drew a figure,” but a versioned object on disk: previewable, roll-backable, able to say “produced by which Cell / which environment / which inputs.”

Implementation shape

  • server/artifacts.py: ArtifactManager (capture, versions, CAS)
  • End of a Cell transaction: savefig, workspace diff → a new version
  • host.save_artifact / native SaveArtifactTool
  • provenance: bind to the kernel generation’s runtime/interpreter/env_name/generation_id
  • In-kernel kernel/provenance.py: object-level lineage tags (can OPENAI4S_PROVENANCE_OFF=1)
  • Retrieval results carry a provenance envelope (DB, request, time, SHA-256)

Stack

  • Content-addressed files + SQLite metadata
  • matplotlib figures: gateway is responsible for savefig (do not autoclose in the worker)

Definition trade-offs

Do Do not
Version is an Artifact’s attribute timeline Overwrite the same path every time with no history
Environment fingerprint comes from the interpreter that actually ran Wrongly stamp the daemon’s package freeze (worse than missing)
When importing a session, do not remap external retrieval provenance Pretend someone else’s machine’s hashes are yours locally

Strengths and costs

  • Strength: a reproducible narrative; the UI can do “produced by cell N.”
  • Cost: large files and version explosion need quotas and a cleanup policy (later).

How it couples to previous and later modules

  • finalize/submit can cite artifact ids; an exported package carries versions and provenance.
flowchart TB
    CELL["Cell writes files / savefig"] --> CAP["ArtifactManager.capture"]
    CAP --> V["artifact_version"]
    GEN["kernel generation"] --> V
    LIN["lineage_edges"] --> V
    V --> UI["Artifact panel"]
    V --> EXP["session package"]

✅ This step’s acceptance: an R Cell artifact’s provenance must not display as a Python freeze; two edits of the same path produce two version_ids.


Part 20

Step 15 · Context compaction

Purpose

A long scientific session will blow the context. Compaction must keep reproducible clues, and must not split a native-tool atomic group or a code/observation pair.

Implementation shape

  • agent/compaction.py: estimate_tokens / should_compact / compact / CompactionPolicy
  • Large outputs land as a content-addressed archive; the message keeps a preview + SHA-256
  • Structured handoff fields: Objective / Constraints / Decisions / Done / In Progress / Blocked / Next Move / Key Artifacts / Active Kernel Generation
  • System prompt and standing context (skills/memory) are rebuilt every turn, not mistakenly wounded by compaction as “the conversation is too long”

Stack

  • Pure-function module + optional LLM summary (SUMMARY_FORK)
  • No hard Store dependency (metadata is injected by the caller)

Definition trade-offs

Do Do not
Groups are unsplittable Simply truncate messages from the front
Per-item budgets (text/images/tools/wire) A single coarse character count
Archive JSON-compatible only Pseudo-serialize in-kernel memory objects into the summary

Strengths and costs

  • Strength: long sessions can still continue; cost is controllable.
  • Cost: summary quality depends on the model; you must test “after compaction it still knows the current generation.”

How it couples to previous and later modules

  • AgentEngine every turn context_policy.prepare; Web passes in branch/ledger metadata.
flowchart LR
    M["messages"] --> E["ContextEstimate"]
    E -->|over threshold| C["compact"]
    C --> S["structured handoff"]
    C --> A["disk archive sha256"]
    S --> M2["short history prepared for the model"]

✅ This step’s acceptance: construct an oversized tool result; after compaction a preview+hash appears, and the tool group is not split into an illegal state.


Part 21

Step 16 · Delegation

Purpose

The main Agent fans subtasks out to child Agents in parallel (literature one group, cleaning another), but there must be budgets and a depth leaf, or it fractally explodes the machine.

Implementation shape

  • agent/delegation.py: tree, budget, cancel, steer
  • Caps: FANOUT_CAP=48, SESSION_CAP=1000, MAX_DEPTH=4 (layer 4 cannot delegate further)
  • host.delegate / collect / stop_child / send_message
  • Cancel is precise to a subtree; a stopped child cannot publish late output
  • Gateway hangs a shared DelegationBudget on SessionState

Stack

  • ThreadPoolExecutor + contextvars to pass the tree
  • Child Agents reuse the same AgentEngine loop

Definition trade-offs

Do Do not
Session-level cumulative spawn budget Refund spawn when a child finishes (prevents farming)
Depth leaf Infinite recursive delegation
In-memory steer queue consumed at turn boundary Stuff a prompt only at start

Strengths and costs

  • Strength: real parallel scientific split.
  • Cost: resource and permission propagation is complex; a child Agent’s workspace/policy must be explicit.

How it couples to previous and later modules

  • Host DelegationService; UI composer has a delegation option; Ledger records child runs.
flowchart TB
    R["Root Agent"] -->|delegate| C1["Child ≤ depth 4"]
    R --> C2["Child"]
    C1 -->|depth<4| G["Grandchild"]
    C1 -->|depth=4| L["Leaf cannot delegate further"]
    R -->|collect| OUT["aggregated results"]

✅ This step’s acceptance: delegate at depth 4 fails; after stop_child there is no late submit_output; exceeding session spawn errors.


Part 22

Step 17 · FIFO execution coordination and session kernel ownership

Purpose

In the same session, Agent turns, user REPL, environment switches, and restore writers must not race for the kernel. You need a FIFO of exact owner + lease, not “fire SIGINT at the session PID at random.”

Implementation shape

  • execution/: coordinator and watchdog (CLI also uses it, so it does not live in server/)
  • server/execution_coordinator.py: Web adapter
  • kernel/supervisor.py: Python/R slot lifecycle; never reads protocol frames
  • Interrupt must carry execution_id + owner.kind + owner.id + frozen lease
  • Environment switch is build-first: only cut the pointer when the new worker is ready

Stack

  • Queue tickets + condition variables
  • generation UUID against ABA

Definition trade-offs

Do Do not
Cancel a queued writer without killing the current writer Session-level blind interrupt
Supervisor and Kernel single-reader are separate Multiple threads read the same stdout
Notebook REPL defaults to a read-only trace Default-open a multi-line REPL

Strengths and costs

  • Strength: restore and concurrency are testable; avoid “killing the wrong generation of process.”
  • Cost: many concepts (ticket/lease/generation); docs must be clear.

How it couples to previous and later modules

  • run_message, cell_run, recovery all come to take a ticket; Artifact capture hangs inside the ticket lifecycle.
flowchart LR
    Q["Agent / REPL / lifecycle / recovery"] --> FIFO["ExecutionCoordinator"]
    FIFO --> OWN["exact owner + lease"]
    OWN --> K["Kernel.execute"]
    STOP["Stop"] --> OWN

✅ This step’s acceptance: while a turn is running, a second message queues; Stop only interrupts the current ticket; a simulated old watchdog cannot kill the new generation.


Part 23

Step 18 · Science connectors and evidence packs

Purpose

Turn UniProt/RCSB/ChEMBL/PubChem/arXiv/OpenAlex etc. into two tools + host.science.*, and force provenance so results become evidence rather than “model memory.”

Implementation shape

  • tools/science.py + host science service
  • Fixed HTTPS endpoints walk the network switch, SSRF/redirect guards, egress, approval, untrusted-output screening
  • evidence.py / session package export
  • workflows/ + openai4s/benchmark/: scientific-workflow benchmarks on a real Store/kernel (LLM/network injectable)

Stack

  • stdlib HTTP client wrapper (via Host)
  • JSON canonicalization version numbers

Definition trade-offs

Do Do not
One connector per library, normalized Expose 10 native tools per library
Hash response bytes Store only “the sequence the model summarized” as truth
Benchmarks assert failure/denied Only assert “didn’t throw, so it passed”

Strengths and costs

  • Strength: the tool table is stable; evidence is re-checkable.
  • Cost: upstream API changes require maintaining normalization; rate limits need a degrade path.

How it couples to previous and later modules

  • The main path of the first user story; Artifact source=provenance; shares the security layer.
flowchart LR
    Q["science_search"] --> C["connector normalize"]
    C --> P["provenance envelope"]
    P --> A["save_artifact(source=P)"]
    A --> E["evidence / export"]

✅ This step’s acceptance: after a mock upstream return, the artifact version contains sha256 and timestamp; changing the bytes changes the hash.


Part 24

Step 19 · Security layering (defense in depth)

Purpose

A single sandbox cannot save a scientific Agent. You must stack mutually independent layers: OS sandbox, env allowlist, pre-exec classifier, dlopen hook, biosecurity, injection annotation, egress, session keys, CSP…

Implementation shape (layer table)

Layer Default Role
OS kernel sandbox auto Seatbelt/bwrap; if enforce fails, refuse to start
Child env allowlist on Do not inherit daemon keys to Python/R
Pre-exec classifier heuristic Screen Cells the agent wrote
dlopen audit on Refuse .so on agent-writable paths
Biosecurity on ALLOW/ESCALATE/BLOCK
Injection scan on Web/PDF/MCP as data, not instructions
Egress allowlist off (can turn on) Application-layer outbound policy
Secret store auto keychain/env; fail closed
Data-dir perms on 0700/0600
Browser headers on CSP etc.
Permission broker on One with step 13
Bash capability token on generation-bound one-shot

Stack

  • Platform branches (darwin/linux); self-test probes
  • Static/heuristic/optional LLM classification

Definition trade-offs

Do Do not
auto visible degrade Silently “think the sandbox is on”
bind loopback Default 0.0.0.0 exposing the LAN
Windows reports unsupported Pretend ACLs are equivalent

Strengths and costs

  • Strength: one layer failing still leaves others; the compliance narrative is complete.
  • Cost: many knobs; CI must run the Linux branch.

How it couples to previous and later modules

  • Runs through Kernel spawn, Host network tools, and post-packaging smoke.
flowchart TB
    CELL["Agent Cell"] --> CL["pre-exec classifier"]
    CL --> SB["OS sandbox"]
    SB --> ENV["allowlisted env"]
    ENV --> RUN["worker"]
    RUN --> RPC["host RPC"]
    RPC --> EG["egress + permission"]
    RPC --> INJ["injection annotate"]

19.4 The correct way to expose locally

When a colleague needs access:

  • ✅ SSH tunnel to 127.0.0.1:8760
  • ✅ An explicit local auth / token latch (if you added one)
  • ❌ Bare 0.0.0.0 on an untrusted network
  • ❌ Treat the daemon as a multi-tenant gateway

docs/security.md opens with: read first, then expose. Copy that sentence into your README.

19.5 Biosecurity and injection are product features, not compliance stickers

Bioinformatics users will hit dual-use content. Trace screening is on by default; web-tool results must be annotated “data is not an instruction.” Turning them off requires an explicit env, and doctor must show the posture.

✅ This step’s acceptance: when sandbox self-test fails, auto reports degraded; enforce does not start a worker; web_fetch by default refuses link-local.


Part 25

Step 20 · BYOC remote compute (optional later)

Purpose

When a local laptop cannot run AlphaFold / large-model inference, send a job to the user’s own GPU (SSH or NIM), while keeping the key and sandbox boundary.

Implementation shape

  • openai4s/compute/: host-side manager/registry
  • openai4s_compute_provider/: remote-side stdlib worker SDK
  • Skills: skills/remote-compute-<id>/ (provider.json + provider.py)
  • host.compute.* / host.fold (strictly no forgery)
  • Two-stage secret erase: entry scrub_secret_env → after loading the provider, erase again by prefix
  • The remote helper wraps another layer of confinement (home directory unreadable etc.); network is not isolated (it has to call APIs)

Stack

  • SSH / docker CLI transport (not a generic pluggable Transport layer)
  • Modal/SLURM not first-class yet (you can hand-write sbatch over SSH)

Definition trade-offs

Do Do not
Job lifecycle stage→run→harvest Mix an inference endpoint and a job into one concept
Provider discovery from remote-compute-* Treat an arbitrary skill as a compute provider
fold fails when there is no result Fabricate a structure and cheat the user

Strengths and costs

  • Strength: BYOC fits lab assets; the core stays thin.
  • Cost: Prototype experience is sharp; docs must write the trust boundary clearly.

How it couples to previous and later modules

  • Built after a stable Host/approval/Artifact; packaging can skip bundling the GPU stack at first.
sequenceDiagram
    participant A as Agent
    participant H as Host compute
    participant R as Remote provider SDK
    participant G as GPU node
    A->>H: compute.create/submit_job
    H->>H: approval + scrub
    H->>R: stage + confined helper
    R->>G: run
    G-->>R: out.tar.gz
    R-->>H: harvest
    H-->>A: artifact paths

✅ This step’s acceptance: without credentials, fold fails and does not fabricate; the helper exits on confinement self-test failure and does not read credentials.


Part 26

Step 21 · Packaging: dmg / pip / WSL (later)

Purpose

Let scientists who will not git clone still use it: Apple Silicon dmg, Linux tarball, Windows zip→WSL2, and pip install openai4s.

Implementation shape

  • pyproject.toml publishes a wheel; skills*/envs*/workflows* must enter package data
  • scripts/ release pipeline, DMG build, verify_release_artifacts.py
  • macOS app: embedded Python + default scientific stack; ad-hoc signature, needs Gatekeeper guidance
  • Windows: the launcher installs into WSL2 and runs the same Linux build; native Windows refuses to start a kernel
  • openai4s doctor / diagnostics / verify-package

Stack

  • uv / pip; platform scripts; optional micromamba kernel environment

Definition trade-offs

Do Do not
Lightweight control-plane venv + optional kernel environment Stuff a full conda into pip deps
An explicit platform support matrix A fuzzy promise of “should run native on Windows”
A release verification script Rely only on a human opening the dmg

Strengths and costs

  • Strength: distribution covers mainstream lab machines.
  • Cost: three-platform release is expensive; notarization/signing is a continuing burden.

How it couples to previous and later modules

  • All runtime invariants first; missing workflows in the package would make the benchmark fake-green—CI must block it.
flowchart TB
    SRC["source + skills/envs/workflows"] --> WHEEL["pip wheel"]
    SRC --> DMG["macOS arm64 dmg"]
    SRC --> TGZ["linux x86_64 tar.gz"]
    SRC --> ZIP["windows zip → WSL2"]
    WHEEL --> V["verify_release_artifacts"]
    DMG --> V
    TGZ --> V

✅ This step’s acceptance: after uv build, the wheel contains skills and workflows; on a clean machine after pip install, openai4s serve can start.



Part 27

Hands-on lab: three minimal commands to verify your skeleton

Before wiring the WebUI, prove the dual plane with the CLI:

# 1) 工具/对话完成路径(不起科学内核也可)
uv run openai4s run "用一句话解释什么是胰岛素,并 finalize。" -v

# 2) Code-as-Action 完成路径
uv run openai4s run "Compute the mean of [4,8,15,16,23,42] and submit it." -v

# 3) 起守护进程(需已配模型或 UI 内配置)
./start.sh
# 浏览器打开 http://127.0.0.1:8760/

Offline tests (default suite mocks the LLM):

uv run pytest tests/test_agent.py -q
uv run pytest tests/test_kernel.py -q

When reading against the source repo, remember: pytest green ≠ release green. There are also response schema/contract, harness, directory README, secret scan, browser smoke.

Part 28

🎬 Full replay: after the user types a prompt, how it is packed, uploaded, received, and run until it returns to the chat window

Scene prompt (the user types in the workbench):

「帮我查人胰岛素 INS,从 UniProt 拉序列摘要,写一段 Python 计算序列长度并画一个最简单的长度标注图,保存产物,最后用中文总结。」

Below, every touch is listed in real module order. Numbers match the sequence diagram’s autonumber.

Module-touch list (read against this)

# Module / file What it does
1 server/webui/app.js send() Read composer, optimistic user bubble, plan/skill rewrite
2 POST /frames (if no session) SessionRunner builds frame + workspace
3 WS sub(frameId) Frontend guarantees subscribe before POST
4 POST /frames/{id}/message gateway.do_POST routes
5 SessionRunner.run_message Admit, queue, start turn thread
6 execution FIFO Take execution ticket / lease
7 prompts.py + skills catalog Assemble system prompt (progressive disclosure)
8 store messages/frames Persist the user message
9 SessionRunner._loop Assemble Engine ports
10 agent/compaction.py prepare context
11 llm/client.py + transport Streaming request to Ark/other
12 agent/engine.py turn loop
13 agent/actions.py route_action Choose Tool / Cell / Finalize
14 tools/* + HostDispatcher e.g. science_search
15 permissions If approval needed, push a card and block
16 agent/ledger.py Write action group/events
17 kernel/* Lazy-start Python, execute Cell
18 sdk/host.py In-Cell save_artifact / submit_output
19 server/artifacts.py Version capture + provenance
20 WebEventSink + WSHub text_chunk / steps / artifacts
21 app.js render Bubble, Activity, Artifact panel
22 finalize or submit_output Terminal completion projection
23 Store assistant message Still there after refresh
sequenceDiagram
    autonumber
    participant U as user
    participant UI as app.js
    participant GW as gateway.py
    participant SR as SessionRunner
    participant FIFO as ExecutionCoordinator
    participant ENG as AgentEngine
    participant LLM as llm.chat urllib
    participant RA as route_action
    participant HD as HostDispatcher
    participant PERM as permissions+SQLite
    participant K as Kernel worker
    participant SDK as sdk/host
    participant ART as ArtifactManager
    participant LED as ActionLedger
    participant WS as WSHub
    participant STORE as Store SQLite

    U->>UI: type prompt, enter
    UI->>UI: optimistic user bubble; clear composer
    alt no currentId
      UI->>GW: POST /frames
      GW->>SR: create SessionState + workspace
      GW->>STORE: persist frame
      GW-->>UI: {id}
      UI->>WS: sub(id)
    end
    UI->>WS: sub(currentId) force subscribe before sending
    UI->>GW: POST /frames/{id}/message wait:false
    GW->>STORE: append user message
    GW->>SR: run_message(...)
    SR->>FIFO: request ticket (queue if busy)
    FIFO-->>SR: obtain owner+lease
    SR->>ENG: _loop assemble ports then Engine.run

    Note over ENG,LLM: Turn 1 · control-plane retrieval
    ENG->>ENG: CompactionPolicy.prepare
    ENG->>LLM: complete(messages, tools, on_delta)
    LLM-->>WS: text deltas (via WebEventSink)
    WS-->>UI: text_chunk → assistant bubble streams in
    LLM-->>ENG: ModelReply(tool_calls=[science_search...])
    ENG->>RA: route_action → NativeToolBatch
    ENG->>LED: open tool action group
    ENG->>HD: Tool.invoke → science_search
    alt needs approval
      HD->>PERM: permission_request
      PERM-->>UI: approval card
      U->>UI: Allow once
      UI->>PERM: resolve
      PERM-->>HD: continue
    end
    HD-->>ENG: normalized result + provenance
    ENG->>LED: append tool results (atomic group)
    ENG->>STORE: usage / frames tokens
    Note over ENG: observation written back to messages, turn+=1

    Note over ENG,K: Turn 2 · scientific-plane Cell
    ENG->>LLM: complete(...)
    LLM-->>UI: optional short explanation text
    LLM-->>ENG: ModelReply(content contains ```python)
    ENG->>RA: route_action → CodeCell(python)
    ENG->>LED: cell attempt group
    ENG->>K: if no worker then lazy spawn + sandbox
    K->>K: run analysis code, matplotlib plot
    K->>SDK: host.save_artifact(...)
    SDK->>HD: host_call save_artifact
    HD->>ART: register version
    ART-->>WS: artifact event
    WS-->>UI: Artifact panel appears
    K->>SDK: host.submit_output(output, bullets)
    SDK->>HD: completion
    HD-->>ENG: CompletionPort set
    ENG->>LED: terminal submitted
    ENG-->>SR: EngineResult(stop_reason=submitted)

    SR->>STORE: project final assistant message (Chinese summary+bullets+artifact delta)
    SR-->>WS: terminal / done-class events
    WS-->>UI: bubble freezes; Stop hides; composer unlocks
    UI-->>U: readable conclusion + clickable figure and versions

    Note over FIFO: release ticket; if a queued message exists, next run_message

On this chain, where is the code you write?

Stage Whose code
Chat UI, subscribe timing, queue hints Yours (WebUI)
REST/WS, session, projection Yours (Gateway/SessionRunner)
Outer-loop routing, completion contract, ledger Yours (AgentEngine family)
Multi-provider HTTP Yours (llm urllib)
Permission, audit, science APIs, Artifact Yours (Host/Store)
Persistent kernel protocol and sandbox Yours (kernel/security)
How specifically to look up INS, draw, explain biology The model’s + Skills recipes
How Ark/Claude weights reason The provider’s

Reasoning can be rented; the scientific runtime must be yours.

If the model chooses “tools-only then finalize”

The path at Turn 2 becomes: FinalizeAction → agent/finalize.py validate → no kernel start → still through Ledger and message projection back to the bubble. That is the meaning of the dual plane: not everything enters the kernel.

If the user sends another sentence while it is running

send() sees S.running: the bubble is marked queued → the same POST messages is accepted by FIFO → after the current ticket ends, the next turn opens automatically. The old “return directly and drop the message” is a product-level accident; you cannot go back.


Part 29

Appendix A · Acceptance checklist

Step Assertion
0 90 seconds: dual plane, stdlib, not IDE, not SaaS, ¥9.9, platform constraints
1 New Tool/Host/SQL/UI landing table is usable; name one wrong landing
2 Placeholder Key counts as unconfigured; provider-specific keys can coexist
3 Under fake transport, ark/anthropic normalized tool_calls have the same shape
4 finalize / tools-over-code / single Cell / unclosed fence unit tests green
5 Tool and host.* of the same method share approval and audit
6 submit_output can end a run; analysis kernel has no delegate attribute
7 Two Cells share variables; pure finalize does not start a worker
8 After a half-open tool group crash, the reducer fills a canonical error
9 Cannot register bash/submit_output; only sole finalize is FinalizeAction
10 Unloaded: no full text; user skills do not shadow bundled
11 serve is a singleton; / and WS work
12 sub then POST; sending again while running queues; refresh history still there
13 Headless deny; after restart once does not replay args
14 R artifact provenance is not a Python freeze; same path two versions
15 Large result becomes preview+sha; tool group is not split badly
16 depth=4 forbids delegate; stop has no late output
17 Stop carries an exact ticket; an old lease cannot kill a new generation
18 science results carry a hash; a benchmark failure case fails because of success
19 sandbox auto visible degrade; fetch refuses private nets
20 fold does not fabricate; confinement failure does not read credentials
21 wheel contains skills/workflows; a clean env can serve

Part 30

Appendix B · Easiest ways to crash

① Treat the completion signal as “the last piece of prose.” Symptom: the UI thinks it ended; the Ledger has no terminal; or the model replies empty. Fix: only finalize_response / host.submit_output / explicit cancel and max_turns.

② Parallel Tool and Cell in the same turn. Symptom: permission and scientific state interleave; cannot audit. Fix: route_action control plane absolutely first; one channel per step.

③ autoclose matplotlib in the worker. Symptom: the figure “was computed” but the Artifact is empty. Fix: gateway savefig/close after the Cell.

④ Artifact environment fingerprint taken from the daemon. Symptom: an R analysis displays a Python package list—wrong provenance is worse than none. Fix: bind to the interpreter freeze of the kernel generation.

⑤ POST before WS subscribe. Symptom: the first turn’s streaming characters are all dropped; at the end a whole paragraph suddenly appears (or nothing). Fix: send() forces sub(currentId) before POST.

⑥ Sending while running does a direct return. Symptom: the user thinks it was sent; the composer is already empty. Fix: FIFO queue + queued style.

⑦ Two Host execution paths (Tool walks A, host.* walks B). Symptom: one side asks for approval while the other quietly runs. Fix: a unique HostDispatcher envelope.

⑧ Register finalize_response into the Tool marketplace. Symptom: a plugin replaces the completion contract; Ledger terminal state is out of control. Fix: Engine’s own FinalizeAction.

⑨ The kernel inherits daemon environment variables. Symptom: API Keys appear in child-process prints; a supply-chain nightmare. Fix: rebuild the environment from an allowlist.

⑩ sandbox auto fails but is treated as isolated. Symptom: demo is safe, reality is naked. Fix: UI/doctor explicitly degraded; to actually force it use enforce.

⑪ Native Windows hard-runs the kernel. Symptom: fd redirect and sandbox explode across the board. Fix: refuse to start + WSL2 wrapping the same Linux build.

⑫ The wheel forgets to pack workflows* / skills*. Symptom: openai4s benchmark 0 workflows 0 failures exit 0—fake green. Fix: package data + verify script.

⑬ Approval payload replayed as execution. Symptom: after restart, “allow” once for an expired dangerous arg. Fix: restart only records the resolution; require Continue/replan.

⑭ Compaction splits a tool atomic group. Symptom: the next request, the provider refuses a half-open tool batch. Fix: keep declaration+results together or archive them together.

⑮ Mid-cell RPC multi-flight. Symptom: frame-interleaved deadlock. Fix: worker _HOST_CALL_LOCK + manager single reader.


B.13 Frame cap guessed as ASCII

Symptom: with lots of Chinese/emoji, the Notebook loses the traceback. Root: character cap × a wrong bytes-per-character assumption. Fix: derive from worst-case UTF-8/JSON escape (see worker.py comments); add a regression test.

B.14 autoclose matplotlib in the worker

Symptom: “the figure clearly showed but the Artifact does not have it.” Root: gateway has not savefig’d yet; the worker closed the figure first. Fix: capture responsibility is on the gateway; the worker must not close on its own.

B.15 Use the daemon’s pip freeze as an R artifact’s environment

Symptom: the provenance panel looks complete; paper reproduction does not match. Root: wrong provenance is worse than none. Fix: bind to the kernel generation that wrote the file; otherwise record missing explicitly.

Part 31

Appendix C · Source index

Baseline: 参考项目/OpenAI4S @ 85e9fa0.

Tutorial step Source anchor
Dual-loop overview docs/architecture.md · CLAUDE.md The dual loop
Step 1 layout openai4s/ package tree · docs/backend-extension-guide.md
Step 2 config openai4s/config.py
Step 3 LLM openai4s/llm/client.py · llm/transport.py · llm/providers/*
Step 4 engine openai4s/agent/engine.py · ports.py · actions.py route_action
Step 5 envelope openai4s/host_dispatch.py · openai4s/host/*
Step 6 SDK openai4s/sdk/host.py · sdk/bash.py · sdk/compute.py
Step 7 kernel openai4s/kernel/manager.py · worker.py · lazy.py · r_kernel.py
Step 7 sandbox openai4s/security/sandbox.py · kernel/environment.py
Step 8 ledger openai4s/agent/ledger.py · store.py · storage/*
Step 9 Tools openai4s/tools/registry.py TOOL_TYPES · agent/finalize.py
Step 10 Skills skills/*/SKILL.md · openai4s/skills_loader/loader.py
Step 11 Gateway openai4s/server/gateway.py · ws_frames.py · server/webui/*
Step 12 send path webui/app.js send · SessionRunner.run_message · _loop
Step 13 permission openai4s/permissions.py · docs/security.md approval section
Step 14 Artifact openai4s/server/artifacts.py · kernel/provenance.py
Step 15 compaction openai4s/agent/compaction.py
Step 16 delegation openai4s/agent/delegation.py · host/delegation.py
Step 17 FIFO openai4s/execution/* · kernel/supervisor.py
Step 18 science openai4s/tools/science.py · docs/science-connectors.md · workflows/
Step 19 security docs/security.md · openai4s/security/* · egress.py
Step 20 BYOC openai4s/compute/* · openai4s_compute_provider/ · docs/compute.md
Step 21 packaging pyproject.toml · scripts/* · docs/platforms.md · docs/startup-guide.md
CLI composition openai4s/agent/loop.py · runtime.py · openai4s/cli/*
Web composition openai4s/server/agent_run.py · gateway.py SessionRunner

Part 32

Close: who this route is for

This is for you if:

  • your users are scientists, and the work objects are data, structures, figures, and evidence packs
  • you are willing to maintain a real kernel + a real ledger, not just wrap a ChatGPT UI
  • you need to start a daemon with stdlib in a dirty local environment, and run through on a ¥9.9-class API
  • you agree: the control plane is auditable, the scientific plane is computable, and the two must not be kneaded into one fake tool

This is not for you if:

  • you are building a repo-scale coding IDE Agent (see this series’ Claude Code / Cline / Codex routes)
  • you are building multi-tenant cloud SaaS with automatic isolation (this architecture’s root of trust is local single-user)
  • you only want to wire an SDK and ship in two weeks, and cannot accept SQLite-ledger and sandbox detail

Last sentence: what people remember about OpenAI4S is not “another Agent loop,” but that it turned the things that are actually expensive in science—persistent computation, evidence, permission, environment fingerprints—into product invariants; the model can be swapped, Ark can be ¥9.9, these invariants cannot.

Two planes, core zero-dependency; what you rent is inference, what you keep is the scientific runtime.


This tutorial is written from 参考项目/OpenAI4S commit 85e9fa0. Voice and structure align with Building an “AI design agent” from scratch (Open Design lineage). Companion reading: OpenAI4S source analysis.

↑