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
Toolcalls; 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@ commit85e9fa0(2026-08, Feat/retrosynthesis workflow triage). Every part cites afileand 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.
Contents
- Look at the end first: what a “scientific dual-plane Agent” looks like
- Build the chain of thought: eight propositions you must believe first
- Step 0 · PRD: Open AI for Scientist
- Step 1 · From PRD to package layout
- Step 2 · Config and data-directory contract
- Step 3 · Multi-provider LLM: the
urllibclient - Step 4 ·
AgentEngineports androute_action - Step 5 ·
HostDispatcher: the capability envelope - Step 6 · Host SDK injected into the kernel
- Step 7 · Lazy persistent kernel + JSON-line protocol + sandbox
- Step 7.5 · Kernel-taste lab: dup2 / frame cap / false truth
- Step 8 · Action Ledger + SQLite Store
- Step 9 · Native Tool catalog and
finalize_response - Step 10 · Skills: code recipes, not JSON Schema
- Step 11 · Gateway:
http.server+ hand-written WebSocket - Step 12 · WebUI send path: composer → bubble
- Step 13 · Permission and approval cards
- Step 14 · Artifact versioning and provenance
- Step 15 · Context compaction
- Step 16 · Delegation
- Step 17 · FIFO execution coordination and session kernel ownership
- Step 18 · Science connectors and evidence packs
- Step 19 · Security layering (defense in depth)
- Step 20 · BYOC remote compute (optional later)
- Step 21 · Packaging: dmg / pip / WSL (later)
- 🎬 Full replay: after the user types a prompt, how it is packed, uploaded, received, and run until it returns to the chat window
- Appendix A · Acceptance checklist
- Appendix B · Easiest ways to crash
- Appendix C · Source index
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:
- Actions are not a single tool table—orchestration walks the JSON control plane; computation walks the persistent-kernel scientific plane;
- State must survive—a 100 000-row DataFrame stays in kernel memory; context keeps only
"<DataFrame 100000×20>"; - The completion signal is a contract—conversation/tool turns use the Engine’s own
finalize_response; inside a scientific Cell there is onlyhost.submit_output(...); - 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;
- The core is zero-dependency—scientists’ machines are dirty, firewalls are many, installing deps explodes easily; a stdlib daemon is a product decision;
- 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.
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.
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
- Retrieve and land evidence: query UniProt / RCSB / PubChem / arXiv; results must be hashable and archivable.
- Compute in a persistent environment: multi-step pandas/scanpy/RDKit analysis must not collapse because “every step opens a new process.”
- Leave an auditable trace: every tool/Cell step enters the ledger; dangerous operations pop an approval card.
- Deliver a shareable pack: export a session / evidence pack to collaborators, not a screenshot of chat.
- 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 withOPENAI4S_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.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)
- The ledger is truth; the UI is a projection. Any PR that “only changes the frontend and calls it done” is sent back.
- Control plane and scientific plane do not race. One step walks one channel.
- Wrong provenance is worse than missing. If you do not know, write the missing reason.
- No subscriber must not default to allow. A security event cannot be released because “nobody is looking at the card.”
- Core zero-dependency is a constraint, not a style. Want to add
httpx/fastapi? First write why urllib/http.servercannot 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.
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/, nosaas/.” - 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.
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/LLMConfigdataclass + zero-dep.envloading- 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 inpython-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_DIRat 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_KEYset,provider=arkcan read it; a template placeholder is judged as no key.
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’sModelPortwraps aChatModel; Host’shost.llmalso walks the samechat().
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: strtool_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.
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/ReplyInterceptoragent/engine.py:AgentEngine.runturn loopagent/actions.py:route_action,CodeCell,NativeToolBatch,FinalizeActionagent/models.py:RunState/ModelReply/ExecutionOutcome/EngineResultagent/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:
- The sole native call and it is named
finalize_response→FinalizeAction - Otherwise, if there is any native call →
NativeToolBatch(whole batch; control plane first) - Otherwise extract the first complete
python /r Cell from the body - 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.pyon CLI and byWebActionExecutoron 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:
- Sole
finalize_response→ stop reason is completion; web_search+ ```python in the body → walk the batch, do not run code;- Two python Cells in the body → extract only the first;
- 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.
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) -> dataopenai4s/host/*.py:WorkspaceFileService,LLMService,DelegationService,CompletionService,SkillService…- soft-fail:
{"error": msg}→ worker turns it intoRuntimeError
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_fetchentering from a Tool and fromhost.web_fetchhas matching approval and audit fields.
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-workerhostsingleton façade- Inject a
host_call(method, args)callback when starting the worker - Codec: SDK snake_case ↔ wire camelCase; omit
Nonefields (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_VERSIONwritten 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_calllock + 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.
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:Kernelon the host side, spawn + single-thread frame readerkernel/worker.py: Python worker;compile(..., "<kernel:N>")+ linecache;getrusageaccountingkernel/lazy.py: CLI one-shot lazy ownershipkernel/r_kernel.py+r_worker.R: same protocol; protocol on fd3/fd4, noise to stderrkernel/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
- Single reader: only the
Kernelmanager thread reads worker stdout; supervisor/watchdog must not sneak a read. - Single in-flight host_call: the worker holds a lock through
host_call → host_response; concurrent RPC will interleave frames. - 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.”
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.
- The model Cell does
print(huge_df); - At the same time
host.web_search(...)sendshost_call; - 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 tosys._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.
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çadestorage/: frame / artifact / action ledger / approvals / settings / memories… repositoriesagent/ledger.py: writeAgentEventas 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.queryis read-only and denylists settings/permissions/ledger and other sensitive tablesPRAGMA 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
commititself; 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.
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 callingexecutedirectly and bypassing the envelope)tools/registry.py:TOOL_TYPESthe unique built-in instantiation point; forbid registeringbash/submit_outputagent/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
ToolSpecinto 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_classto keep it.
How it couples to previous and later modules
route_actionrecognizes 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 solefinalize_responseproduces a structured completion; when other tools are mixed in, it does not walk FinalizeAction.
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 isuserorigin 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-Cellhost.skills.*; remote compute skills carryprovider.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.
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 byroot_frame_id, broadcast, disconnect resume cursorSessionRunner: session state, job queue,run_message,_loopserver/webui/:index.html/app.js/style.cssno 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 (seews_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.shis 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.
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)
- composer:
send(text, opts)inserver/webui/app.js - If there is no session:
POST /framescreates one, andsub(frameId)subscribes to WS - Optimistically render the user bubble; if
S.runningthen mark queued (server FIFO accepts) sub(currentId)first, then POST—otherwise the firsttext_reset/text_chunkis dropped for lack of a subscriberPOST /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 HTTPSessionRunner.submit_message→ FIFO ticket +MessageJob→run_messageSessionRunner._loopassemblesAgentEngine+ChatModel+WebActionExecutor+CompactionPolicy+WebEventSink- During engine turns:
TextDelta/ tool / cell events →emit→WSHub.broadcast(each stamped with a monotonicseq) - The frontend stitches chunks into the assistant bubble; tools become Activity cards; file writes become Artifacts
- Completion:
server/completions.pyprojects structured output / bullets / artifact delta as the final assistant message, then sends a terminalframe_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 viaseq+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
- Generate and remember the admission id before POST (comment/pin scenes); a server-issued id does not help after disconnect.
sub(frameId)before POST; the first-packet delta must not be dropped.- Use the
S.runningsnapshot at dispatch time to decide whether to openTurnTicket—there is an await in the middle; an old snapshot expires. - Do not let the header model overwrite a session-pinned revision (the source comments record this pit).
- 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:falsepath) - Inside the thread: take a FIFO ticket →
_loop→completionsproject 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 .../messagereturning 202; WS hastext_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.
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 consumeOPENAI4S_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.
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/ nativeSaveArtifactTool- provenance: bind to the kernel generation’s runtime/interpreter/env_name/generation_id
- In-kernel
kernel/provenance.py: object-level lineage tags (canOPENAI4S_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.
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
AgentEngineevery turncontext_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.
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
DelegationBudgetonSessionState
Stack
ThreadPoolExecutor+contextvarsto pass the tree- Child Agents reuse the same
AgentEngineloop
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_childthere is no latesubmit_output; exceeding session spawn errors.
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 inserver/)server/execution_coordinator.py: Web adapterkernel/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.
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.
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.0on 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,
autoreports degraded;enforcedoes not start a worker;web_fetchby default refuses link-local.
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/registryopenai4s_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.
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.tomlpublishes a wheel;skills*/envs*/workflows*must enter package datascripts/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
workflowsin 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 afterpip install,openai4s servecan start.
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.
🎬 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.
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 |
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.
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 |
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.