Skip to content

Refactor/v0.1.0 runtime rewrite - #2

Merged
yukikurage merged 72 commits into
mainfrom
refactor/v0.1.0-runtime-rewrite
Jun 1, 2026
Merged

Refactor/v0.1.0 runtime rewrite#2
yukikurage merged 72 commits into
mainfrom
refactor/v0.1.0-runtime-rewrite

Conversation

@yukikurage

Copy link
Copy Markdown
Member

No description provided.

yukikurage and others added 30 commits May 30, 2026 05:15
…hard + DB)

2026-05-30 設計議論のまとめ。 6 ファイル (overview / value-and-streaming /
runtime-architecture / storage-schema-and-api / implementation-plan /
v0.2-streaming)、 decision log D1-D36。 observable streaming は v0.2 送り、
v0.1.0 は complete blob のみ。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ec [Phase A TS]

- Value: string/file/secret carry BytesRep (inline | ref); file is always ref.
  helpers mkString/mkSecret/inlineText/tryInlineString.
- value-codec: $ref envelope (module,id,as,hash,size), as-is (no promotion),
  decode routing adds $ref. valueToRaw/From stay sync.
- value-secret-codec / snapshot: rep-aware, VALUE_KIND_TAGS += file.
- equality: string=content (hash/text), file=identity (module,id).
- agentLiteral.snapshot deferred to Phase E.

Tests still reference old value shape (green after Phase A完了).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
opaque byte sequence、 string と disjoint、 identity-typed (file==file は参照同一性)、
literal なし。 lexer KeywordFile → parser → SemanticTypeFile → ConstraintGenerator /
NormalizedType (fileLayer) / Solver shapeKind / Render / Schema ($ref envelope schema)。
Exhaustive は catch-all で処理 (wildcard 必須)。 680 compiler tests green。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the content-addressed byte-sequence store backing string/file/secret
refs. Three layers per docs/2026-05-30-storage-schema-and-api.md §2:

  - value_refs        ephemeral CORE/FFI values (reachability GC)
  - api_files         persistent API-owned files (user-managed)
  - value_blobs+chunks project-wide content-addressed bytes (refcount dedup)

`ValueStore` interface lives in katari-runtime (lower layer, so engine
modules can consume it); katari-api-server provides the Postgres + memory
impls and wires it onto the Storage facade (participates in withTransaction).

- runtime: hashBytes/hashText (Blake3 via @noble/hashes, pure-TS), ValueStore
  interface + types, exported from index
- produce: putComplete + open/pushChunk/close (host-buffered, re-chunked at
  close, no per-chunk DB write — D32); MAX_PRODUCE_BYTES cap
- consume: getState / fetch / fetchRange (ref→hash→blob; module=api → file)
- files: create/get/list/delete + persistRef (ephemeral→persistent promote
  sharing the blob); GC primitives sweepInstance / sweepUnreachable
- dedup via value_blobs.ref_count; blob swept at 0

Schema additions are additive (existing tables untouched); the project-scoping
+ engine_shards migration lands with the Phase E consumer rewrite. Data plane
HTTP routes follow next.

ValueStore unit tests: 12 passing (produce/consume/dedup/range/file CRUD/
persist/sweep/tx rollback). Full TS workspace builds green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Katari Protocol data plane per docs/2026-05-30-storage-schema-and-api.md §4.2.
Mounted at /project/:projectId/value:

  GET /:module/ref/:id            full bytes
  GET /:module/ref/:id?range=N-M  partial bytes (also honours `Range:`)
  GET /:module/ref/:id/state      metadata (state / hash / size / contentType)

Read-only by design — production is module-internal (FFI sidecar / in-process),
not exposed here. module ∈ {core, ffi, api}; api ids resolve through api_files.
errored refs → 409, unknown → 404, ranges → 206 + Content-Range. Path carries
no snapshot (a value's owner is a module, not a code version — D24). Auth rides
the app-wide bearer for now; per-module sidecar tokens land in Phase C.

Integration tests: 7 passing (fetch / range query / Range header / state /
api file / 404 / 409).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e C]

The handler-facing `katari.value` namespace. A byte-sequence arg arrives
inline (bare JSON string) or as a `$ref` envelope; this client erases the
difference so a handler writes `await katari.value.text(args.history)`
whether history is a 3-word inline string or a 200 KB ref pulled over the
data plane (docs §4.2 / Phase C deliverable #2 consume half).

  fetch(v)            → Uint8Array  (inline encode | data-plane GET)
  text(v)             → string      (UTF-8 decode)
  fetchRange(v,o,l)   → Uint8Array  (?range=o-end for refs; local slice inline)

- $ref → GET {KATARI_PROTOCOL_URL}/project/{id}/value/{module}/ref/{id}
  with Bearer KATARI_PROTOCOL_TOKEN; config read lazily so inline-only
  handlers (and import-time tooling) need no protocol env
- non-byte-sequence RawValue / missing env / non-2xx all throw clearly
- injectable env + FetchLike for hermetic tests

9 unit tests (inline fast-path / $ref URL + auth / range / errors / guards).
Produce side (put/open/persist) + sidecar env wiring + FfiModule + the
23-blob-echo e2e sample follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FFI/CORE produce surface (docs §4.3) — separate from the read-only data
plane (production is each module's own business, not the generic protocol):

  POST /project/:p/value/:owner/produce         bytes → ephemeral ref
  POST /project/:p/value/:owner/ref/:id/persist ephemeral → persistent api file

owner ∈ {core, ffi}. Produce reads X-Katari-Semantic-Kind + Content-Type +
optional X-Katari-Owner-Instance, host-buffers the body, and writes via
ValueStore.putComplete (re-chunked at the store). persist promotes through
persistRef (shared blob). Returns a {module,id,hash,size,contentType?} shape
the produce client wraps into a $ref. Establishes the real contract the
katari-port produce client builds against next (mirrors how the consume
client followed the existing data plane).

Integration tests: 5 passing (produce→data-plane round-trip / semantic-kind +
content-type / persist + shared blob / 404 / non-producer owner 400). Full
api-server suite green (35).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the `katari.value` surface against the now-real produce-route
contract. A handler produces and returns a ref:

  put(bytes, {as, contentType})   → $ref     (POST .../{owner}/produce)
  open({as}) → pushChunk* → close → $ref     (host-buffered, one blob at close)
  persist(ref, {displayName})     → $ref api  (POST .../{owner}/ref/{id}/persist)

- owner from KATARI_SIDECAR_OWNER (produce only); persist targets the source
  ref's own module (core/ffi) and yields a module=api file ref (as="file")
- produce response {module,id,hash,size,contentType?} is wrapped into a
  `$ref` RawValue; abort discards the host buffer with no store call
- persist rejects non-ephemeral / non-ref inputs

6 more unit tests (produce URL+headers+body / SIDECAR_OWNER required / stream
concat / abort no-op / persist promote / guards) — 15 total in katari-port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the metadata-only byte-sequence semantics ref-aware, no fetch:

  - == / != on string|secret compare content hashes (inline reps hash their
    UTF-8 text, refs carry their hash). Resolves the Phase-A "mixed inline/ref
    requires materialize" throw — it never needed bytes, only the hash.
  - match against a string literal compares the literal's hash to the
    subject's, so a ref subject matches the same as an inline one.
  - file == file stays identity (module, id) — already correct.

Centralised in engine/value.ts: bytesHash / bytesContentEqual / bytesEqualsText
(shared by prim equality + pattern match). These are pure metadata ops.

The fetch-requiring half of Phase D (materializeBytes + concat / format /
to_string / from_string on ref operands) needs the engine's async quantum,
which the runtime-architecture builds as part of the Phase E actor host — and
no refs reach the engine until snapshot promotion / FFI produce are wired. So
that half sequences with E; inlineText still throws loudly on a ref until then.

6 unit tests (inline==ref / ref==ref by hash / file identity / match literal
vs ref + inline). Full workspace builds green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The design doc the implementation-plan mandates before touching Phase E.
Turns runtime-architecture's durable-object concept (D12-D23) into a
concrete implementation plan against the measured current code.

Key findings driving the design:
  - Module.feed/persist/load are ALREADY async → async-ifying the engine
    is localised to drive/step/applyEvent + CoreModule.feed awaiting them
  - State is flat → the shard split is a restructure of flat State into
    per-agent EngineShard + project-local ProjectIndex (2-level routing:
    id→ShardId in the index, id→ThreadId in the shard)
  - Orchestrator.tick (cold per-request) → warm ProjectActor (serial loop)

Specifies: flat→shard/index field mapping, event→shard routing table
(incl. the escalate-routing porting risk, flagged test-first), async
quantum + materialize injection (where D-async lands), CoreModule
projectId+multi-snapshot rewrite, ProjectActor host, agentLiteral.snapshot,
crash recovery, 8 invariants, file impact list, open questions.

Staged rollout to de-risk: E0 async engine (D-async merges here, behaviour-
preserving) → E1 shard storage → E2 actor host → E3 multi-snapshot + sidecar
env (Phase C tail merges here). Both the Phase C and Phase D tails converge
on E, as noted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the engine deterministic-but-async so content-transform prims can
fetch ref bytes (runtime-architecture §5). Confined to the create-path —
the only place a prim is evaluated:

  executePrim → primOps.create → dispatchCreate → onCreate → step → drive
  → applyEvent   (all async; the other 5 thread-ops + internal events stay
  sync, since they spawn work via the queue rather than evaluating prims)

- StepCtx.materialize(rep): inline → resolved immediately, ref → injected
  RefFetcher (ValueStore-backed at the host). The fetch is a pure function
  of the content hash → the state transition stays deterministic.
- concat awaits materialize for ref operands; inline operands stay a direct
  text join (no fetch — the common path stays cheap). secret taint preserved.
- applyEvent(state, event, fetchRef?) — fetchRef defaults to throw-on-ref;
  CoreModule passes none yet (CORE state holds no refs until persist-time
  promotion lands in E1), so the ref path is dormant-but-wired.

"Always async" over a sync-fast-path: Katari is an orchestration language
(agents take a few steps then await LLM/IO), so per-prim microtask overhead
is negligible vs the actual work — simplicity wins.

Behaviour preserved: integration.test.ts green after adding awaits (delegate→
add→ack, missing-entry escalate, terminate cascade). New async-prim.test.ts
(5) covers inline/ref/mixed concat + taint. Full build + api-server e2e (35)
green. format/to_string/json keep inlineText (throw on ref) until refs reach
them; same pattern applies when they do.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Solves the motivating problem — a large inline string (e.g. an accumulated AI
conversation) was copied into the CORE checkpoint on every persist. Now CORE
state persist promotes any inline string over a byte threshold (4 KiB default)
to an owner=core value-store ref; the checkpoint carries only the small handle.
This also makes E0's materialize non-dormant: refs now actually flow into the
engine and content-transform prims fetch them.

- snapshot.ts: promoteCheckpoint(checkpoint, promote, threshold) — an async
  structural walk that replaces large `string` inline reps with refs (secrets
  stay inline; already-ref strings pass through unchanged → stable id across
  persists; nested strings in arrays/records/tagged are promoted too)
- CoreModule: projectId + valueStore injected; persist promotes before encrypt
  (disjoint: promotion=strings, encryption=secrets); feed threads a
  ValueStore-backed fetchRef into applyEvent so reloaded refs materialize
- orchestrator-adapter: ResolvedSnapshot carries projectId; CoreModule wired
  with snapshot.projectId + tx.values (tx-scoped, so a rolled-back persist's
  blob writes roll back too)

Observationally transparent (E-design invariant #8): == / match compare by hash
(no fetch), concat materializes — so existing runs are unaffected. promotion
test (3): large promotes / small + secret stay inline / nested / ref round-trip
(== by hash + concat by materialize). Full build + api-server e2e (35) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-async]

With persist promotion live, a large string can now be a ref anywhere in CORE
state — so every prim that READS string content (not just concat) must
materialize it instead of throwing on `inlineText`. Without this, promoting a
>4 KiB string and then to_string/json/from_string-ing it would break.

- to_string / json.stringify: materializeValueDeep — recursively replace string
  refs with inline before valueToRaw / jsonTaggedToRaw, so the serialized JSON
  carries real content, not a `$ref` envelope (handles refs nested in
  arrays / records / tagged values)
- from_string / json.parse: materialize the ref text arg before JSON.parse
- get_field / record.get / set / remove / has: materialize the (rare) ref key
- format stays a value passthrough (no text read → already ref-safe)

This completes the D-async content-transform prim set begun with concat in E0.
ref-prims test (6): to_string ref + nested array / from_string ref JSON /
record.get + has ref keys / inline still works. Full build + api-server e2e
(35) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 4 test files deferred during the Phase A value-model rewrite, brought to
green so the runtime suite is a complete regression net again:

- value-codec / value-secret-codec / snapshot-secret: {kind:"string"|"secret",
  value} → mkString / mkSecret (rep-based); redact assertion reads via inlineText
- prim-ops: executePrim is now async + takes a materializer — awaited with an
  inline-only stub `M` (throws if a fetch is unexpectedly attempted); throw
  cases use rejects.toThrow; applyEvent awaited

Full runtime suite green: 10 files, 72 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The storage foundation for per-agent sharding (docs/2026-05-30-phase-e-actor-
host.md §2/§4). A shard IS a State scoped to one agent instance and persists
as an EncryptedEngineCheckpoint (the existing checkpoint codec, reused
verbatim — no separate shard serialization) keyed by (projectId, shardId).

- runtime engine/shard.ts: ProjectIndex (delegationId/escalationId → shardId
  routing table), ShardStatus, ShardStore + ProjectIndexStore interfaces.
  Documents the verified routing: an inbound escalate/ack returns to the shard
  that issued the delegate/escalate, so all 6 events route by index lookup.
- api-server: PgShardStore / PgProjectIndexStore over engine_shards /
  project_index (Phase B schema), InMemory versions, wired onto the Storage
  facade (participate in withTransaction).

shard-store test (6): checkpoint round-trip / listActive status filter / delete
/ project scoping / index round-trip / facade tx rollback. Build + api-server
suite (41) green.

The CoreModule rewrite to use these (split flat State → per-shard, index-based
routing, on-demand load) follows next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the flat per-snapshot State (which held ALL agents of a snapshot in one
State) into per-agent-instance shards + a project-local routing index. A shard
IS a State scoped to one agent; the engine's applyEvent runs unchanged on it.
This is the memory/IO win — an event loads only the touched shard, not the
whole snapshot's state.

CoreModule now:
- routes each event to its shard via the index (delegate → new shard; every
  ack / terminate / escalate → the issuing shard, an O(1) index lookup — the
  escalate path was verified against the engine's pendingDelegateOut routing)
- loads shard bodies on demand in feed() (the tx-scoped ShardStore /
  ProjectIndexStore are injected at construction, since feed gets no tx),
  caches them for the tick, persists dirty shards (with per-shard promotion)
  and deletes completed ones (threadCount 0 → no replay → no retention)
- reconciles the project index from each shard's local maps after applyEvent

CoreTx collapses to empty (stores are held); the orchestrator's checkpoints
plumbing and CoreCheckpointStore are removed. ResolvedSnapshot.projectId feeds
the per-project shard scope.

Validated end to end: api-server e2e (41) drives multi-agent CORE→CORE
delegation through per-shard states + index routing; integration.test asserts
the index fully purges once both agent shards complete. Full build + all
suites green (runtime 72 / api-server 41 / port 15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock in the host / bus / Module responsibility split from the design
discussion (§14):

- Module = independent entity: implements the katari-protocol (feed) AND owns
  its domain features as methods (ApiModule.startRun / uploadFile / ...). It
  opens its OWN tx (1 quantum = 1 tx) and self-serializes. Warm (resident),
  holds root storage.
- bus = pure router (dispatch by event.to). No tx / lock.
- host = a thin proxy for each Module: external trigger → Module method call,
  plus driving the bus. No tx, no serialization.

The old "1 request = 1 tx + 1 snapshot lock wrapping the whole drain" is
dropped (the coupling §1 criticised). Serialization becomes CORE's own
per-project mutex (in-memory); per-shard concurrency (v0.2) is then a
CORE-internal mutex-granularity change, not a host change. Module interface
simplifies to feed() only (each self-tx); load/persist phases removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…) [Phase E3]

snapshot is a CORE/FFI-private axis, so it must NOT ride the katari protocol
(bus events). Instead it's carried INSIDE the (bus-opaque) agent def id, on the
ONE identifier that loads versioned code on the receiver: the delegate target.

Rule (confirmed in design): an id carries the snapshot iff it loads
snapshot-dependent code at runtime = a delegate target's agent def id (CORE
agent runs versioned IR; FFI ext picks the per-snapshot sidecar). Everything
else stays a bare qname — interpreted within an already-running shard whose
snapshot is fixed:
  - request id (escalate)  — dispatched in the handler shard's snapshot
  - constructor id / $constructor — a runtime data tag (qname string + fields),
    no snapshot lookup, like number/string
  - primitive.throw (escalate) — protocol-common, snapshot-free
  - closures — run in their captured scope, snapshot-independent
And NOT delegations to ENV / API — those modules are snapshot-independent.

- agent-def-id: CoreAgentDefId.qname / FfiAgentDefId carry optional snapshot,
  encoded `qname@<snapshot>` (`@` ∉ qname/UUID/closure; compiled schema /
  get_metadata stays bare). CORE and FFI qname encodings are identical.
- CoreModule: stamps the issuing shard's currentSnapshot on outbound delegates
  to CORE/FFI only (ENV/API left bare; closures inherit); decodes the inbound
  delegate's snapshot → the new shard's currentSnapshot; resolves IR via getIR
  (per-snapshot, defaults to the single irModule). Tracks currentSnapshot per
  shard; persists it. ShardStore.get returns it on load.
- ApiModule.startRun stamps the run's snapshot on the root CORE delegate.

agent-def-id test (6) + shard-store/get round-trip. Full build + all suites
green (runtime 78 / api-server 41 / port 15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The snapshot rides inside the agent def id (CORE/FFI-private, opaque to
the bus). FFI is the boundary where that id meets a sidecar whose handler
registry is keyed by the BARE qname (the sidecar already IS the right
snapshot's code), so:

- inbound CORE→FFI delegate (`ext.tool@snap`) → strip → `ipcDelegate ext.tool`
  (store row keeps the bare form so recovery's ipcDelegateRestarted matches)
- ext-spawned CORE child (`some.agent`) → stamp → `delegate some.agent@snap`
  so CORE creates the child shard on the matching IR version

throw / escalate stay bare (requests, not delegate targets). The three
stamp helpers (agentDefIdSnapshot / stripAgentDefIdSnapshot /
stampAgentDefIdSnapshot) move to agent-def-id.ts so CORE + FFI share one
place that knows "the snapshot lives inside the id".

runtime 81 / api-server 41 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… WIP]

Replace the per-snapshot Orchestrator with a warm per-project actor model
(user's confirmed Module-self-contained design):

- Module interface = feed() only (drop host-driven load/persist)
- CoreModule: warm per-project actor — holds shard cache + routing index +
  IR memo in memory across quanta, opens its OWN tx per feed via a
  CoreStorage provider (1 quantum = 1 tx), async getIR over snapshots.
  Snapshot is CORE-private (shard.currentSnapshot), never on the protocol
  delegations row.
- ProjectActor / ProjectActorHost: warm actor registry + per-project serial
  queue. Thin — no tx, no lock. Bus stays a pure router.
- FfiMux: per-project multiplexer over per-snapshot FfiModule lanes (a
  sidecar is per-snapshot; route delegate by stamped snapshot, others by
  FFI-private delegation/escalation→snapshot lookup).
- modules/storage.ts: CoreStorage / CoreTxStores / EnvStorage providers
  (runtime→host hand-off so modules open their own tx).
- EnvModule / FfiModule: drop load/persist from the Module interface
  (FfiModule.load() stays as a lane method the mux drives).
- Delete runtime orchestrator/ (replaced by actor/).

runtime build + 81 tests green. NOTE: api-server still imports the removed
Orchestrator — workspace build is RED until the api-server migration
(next commit) lands the host wiring + ApiModule warm + schema project-scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otocol [Phase E]

Migrate api-server onto the runtime's warm per-project actor. The
per-snapshot Orchestrator (tick + advisory lock + AsyncLocalStorage tx
threading) is replaced by ApiServerActorHost: one warm ProjectActor per
project, each module opening its own tx (1 quantum = 1 tx); the host holds
no tx and no lock, serialization is the actor's per-project serial queue.

Protocol tables made project-scoped (user's steer: a delegation /
escalation is a katari-protocol entity, not a CORE-private one):
- delegations / escalations: snapshot_id → project_id. The snapshot a
  delegation runs is module-private state (CORE engine_shards.current_snapshot,
  FFI ffi_pending_*.snapshot_id, API runs_audit.snapshot_id), never on the
  shared protocol row.
- recovery's live-snapshot set moves from delegations to
  ffi_pending_delegations.listLiveSnapshotIds (the sidecars to respawn).

Pieces:
- ApiModule: warm + projectId + self-tx; startRun resolves (projectId,
  snapshotId?) inside its own tx and stamps the root delegate's snapshot.
- actor-host.ts: builds core/api/env/ffi-mux per project, wires sidecar
  message routing (snapshot→project→actor→lane) + boot recovery.
- StorageDelegationStore projectId-bound; StorageFfiStore carries projectId
  for its unified-delegations mirror; StorageEnvStore is root-backed (auto-
  commit per op).
- pg + memory storage: delegations/escalations project_id; FFI
  listLiveSnapshotIds. SnapshotNotFound/NoSnapshotForProject move to
  snapshot-service.
- routes / recovery / bin / test helpers rewired to the host.

New ffi-e2e test drives a real CORE→FFI→sidecar→CORE→API round-trip through
the actor + FfiMux (validates the snapshot strip/stamp end-to-end).

Full TS suite green: runtime 81 / api-server 42 / port 15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….throw)

ApiModule detected the unhandled-throw escalate by a hand-typed
`prim.throw`, but every emitter (engine runner, FFI sidecar error, ENV arg
error, engine handle-ask) uses `primitive.throw` — so the match never
fired and throws were silently recorded as open escalations while the run
hung in `running` forever.

- Centralise the id as a single exported `THROW_REQUEST_QNAME =
  "primitive.throw"` (Katari language vocab — the compiler lowers `throw`
  to module `primitive` / name `throw`). All emitters + ApiModule reference
  it, so the strings can't drift again.
- CoreModule now acks a `terminate` for a delegation with no live shard
  (e.g. a missing-entry root that errored before spawning), so the
  throw → terminate → terminateAck → terminal `error` loop closes instead
  of leaving the run stuck `cancelling`.
- CoreModule evicts a shard from its warm cache if applyEvent throws
  (in-place mutation would otherwise poison the warm copy across feeds).
- New e2e test: running a non-existent agent reaches `error`, not stuck.

runtime 81 / api-server 43 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First increment of #5 (content-addressed closures). A closure now has two
representations:
  - `{closureId}`  — in-shard (index into state.closures), the in-memory form
  - `{ref}`        — content-addressed: a ref to the serialized {blockId,
                     snapshot, captured env} blob. The canonical wire form a
                     closure takes when it crosses a shard boundary.

ref-in-ref foundation: a closure ref's blob references its env's nested refs,
so the value graph stays content-addressed and acyclic — the eventual GC
(Phase G) is reference-counting, not cross-shard mark-sweep (untenable
multi-server). The wire carries only the hash (clean bus).

- value.ts: closure union + isLocalClosure narrowing.
- value-codec.ts: `$ref` envelope gains `as: "closure"`; round-trips the
  content-ref form (local id still encodes as `$agent:closure:N`).
- in-shard ops (gc / prim.get_metadata / delegate) narrow to the local form.
- value-secret-codec EncryptedValue mirrors the union.

Design recorded in memory. runtime 82 / api-server 43 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hase E / #5]

Pure functions for freezing a machine-local closure into a content-
addressed value-store blob and grafting it back into a receiving shard:

- serializeClosure: walk the captured scope chain to root, promote nested
  local closures to their own refs (finite DAG), drop the single self-
  reference into selfVar. Blob is Value-form (engine-internal, never
  crosses FFI), so no wire codec. Refuses a captured secret (plaintext-at-
  rest gap, documented for Phase G).
- materializeClosure: graft scopes with fresh ids (remap parents), re-bind
  the self-reference to the new closure id, register state.closures — after
  which the existing closure:N dispatch (resolveDelegateTarget) runs it.

Recursion handled without SCC hashing: Katari local agents can only self-
reference (siblings cannot forward-reference, per Lowering), so one selfVar
suffices. Keeps the pure engine free of snapshot/store concerns — CORE does
serialize at the outbound boundary, materialize at inbound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nbound [Phase E / #5]

Closures now cross shard boundaries (in-shard invocation was already broken
in the warm-actor model — a closure delegate routes to a new shard whose
agentDefId had no snapshot, so getOrLoadShard threw). The fix:

- agent-def-id: add a closureRef CoreAgentDefId form (carries the content
  ref; snapshot rides in the blob, not an @ stamp) + agentDefIdClosureRef.
- delegate.ts: a ref-closure target encodes closureRef (was: throw).
- CoreModule outbound: serializeOutboundClosures freezes any escaping local
  closure (delegate target + every arg) to a value-store blob, rewriting the
  target closure:N to closureRef. Top-level secret args pass; secrets inside
  a captured scope are refused.
- CoreModule inbound (getOrLoadShard): a closureRef delegate fetches +
  materializes the blob into a fresh shard, then rewrites the target back to
  closure:N so the existing resolveDelegateTarget dispatch runs the body.
- runner: guard — a closureRef must never reach the engine.
- value-store: ValueSemanticKind gains closure.

e2e test: main makes a local agent capturing base=10, invokes it across
shards; the captured env survives the blob round-trip (returns 10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_metadata on a not-yet-invoked closure ref can't introspect without an
async blob fetch (the sync metadata path can't do it); invoking materializes
it. Clarify the v0.1.0 limitation in the thrown message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A spawned sidecar runs user ext code and reaches the value data plane
(produce / consume / persist) via katari-port, which reads its coordinates
from the process env. Thread those through:

- SidecarManager: factory + ensureStarted gain an `env` arg.
- ApiServerActorHost: takes SidecarProtocolCoords (baseUrl + token); its FFI
  lane backend stamps KATARI_PROTOCOL_URL / _TOKEN / _PROJECT_ID (per-project)
  / _SIDECAR_OWNER=ffi on ensureSidecar.
- bin.ts: baseUrl defaults to http://127.0.0.1:$PORT (the sidecar is a local
  subprocess), token = the api key. PROTOCOL_TOKEN is a distinct env name so
  the subprocess KATARI_API_KEY filter does not strip it; matches the
  Authorization: Bearer the data-plane auth middleware expects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p [Phase C]

An ext (makeBlob) writes bytes to the value store via katari.value.put and
returns a file ref; CORE holds the ref; a second ext (readBlob) reads it back
via katari.value.text. Exercises the Katari Protocol data plane end to end,
using the sidecar env wired in 26dd5b9.

Surface verified against the current compiler (file is a primitive type:
Lexer.KeywordFile / Parser.parsePrimitiveType / SemanticTypeFile). The on-PATH
katari binary predates Phase A's file type, and the runtime e2e needs a
combined harness (data plane + a REAL subprocess sidecar, not the MockSidecar
the samples-e2e suite uses) — so no samples-e2e entry yet; that harness is the
remaining Phase C validation step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…al form [Phase E / #5]

Pivot the committed local-form design to the agreed eager-ref model (user
direction): a closure VALUE is always a content ref; there is no machine-local
closureId value form.

- A closure literal spawns a MakeClosureThread (a leaf thread, like a prim):
  its async create freezes the captured scope chain into a value-store blob via
  the new ctx.putBlob (symmetric to ctx.materialize) and dones the parent with
  {kind:closure, ref}. The blob is written BEFORE the ref is returned, so a ref
  never dangles. Modelled as a thread because persisting is async and in this
  engine a step that waits IS a thread — the statement loop + done stay sync.
- State gains a snapshot field (the shard's version, set by CoreModule on
  create/load); make-closure stamps it into the blob so (blockId, snapshot)
  uniquely identifies the body independent of the invoker.
- Value union collapses to {kind:closure, ref}; value-codec drops the
  agent:closure:N encoding (closures are ref-only); value-secret-codec + gc
  drop the local-closure cases (state.closures is now a one-shot
  materialize-to-dispatch handoff, swept by GC).
- CoreModule provides ctx.putBlob (putComplete owner=core kind=closure); sets
  state.snapshot; drops serializeOutboundClosures (closures are refs already);
  materialize re-binds the self var to the closure's own ref.
- get_metadata on a closure currently throws (introspection needs an async blob
  fetch) — addressed next.

runtime 86 / api-server 43 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
yukikurage and others added 27 commits May 31, 2026 22:57
…Phase G slice 3)

- FFI in-flight protection: katari-port's `katari.value.put` stamps
  `X-Katari-Owner-Delegation` = the running handler's delegation id (read from
  the AsyncLocalStorage delegation context), so an ext-produced ref is owned by
  its ext delegation while the handler runs and is re-owned by the parent on
  delegateAck — closing the "produced but not yet returned, GC sweeps it" race.
- crash backstop: GcService.sweepAllProjects() drops refs whose owning entity
  is gone (not in delegations ∪ runs_audit ∪ escalations), reclaiming releases
  lost to a crash. Wired into boot recovery (runs before traffic, so it never
  races concurrent production; periodic-while-live is deferred to a
  serialized-quantum variant).
- end-to-end proof (gc.integration.test): a real `agent main()` doing
  `string_to_file("…")` — when the file does NOT escape the run its blob is
  freed on completion (blobs == 0); when the run RETURNS it the blob survives
  owned by the run (blobs == 1).

runtime 94 / api-server 60 / port 15 / e2e blob-echo green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…history (Phase G)

Honor "escalate also moves ownership up": when the API records an escalation,
the escalation entity (persistent until answered/cancelled) takes over any ref
the escalating delegation owns in the escalate args. Without this, a file
carried up in an escalation is owned by the escalator's shard and gets freed
when that shard completes — so viewing the escalation in history after the run
ends would dangle. transferOwnership only moves refs OWNED BY the escalator
(api_files / higher-owned refs untouched). Exports collectRefs from the runtime.

Known v0.1.0 residual: an in-CORE `handle` block that stores a ref captured
from an escalate's args into a handle-scope state var outliving the escalator
is not yet covered (symmetric CORE-side transfer — uncommon pattern, follow-up).

api-server 60 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…model

Recast the v0.1.0 runtime onto a first-class Entity model
(docs/2026-06-01-entity-model.md), replacing the ad-hoc "ref owner =
delegation/run/escalation id" GC and the value_refs/api_files/runs_audit
tables. No compat layer — a clean switch.

Core ideas:
- Two records, distinct owners + nested lifetimes: a `delegations` row is the
  ISSUER's request edge (created at delegate-emit, deleted at the result ack);
  an `entities` row is the RECEIVER-managed execution node (minted when the
  module begins processing, self-deleted on terminal). The receiver mints its
  entity from the bus event + ambient context ALONE — no cross-server read.
- Refs are owned by exactly one Entity (or transiently NULL mid-ascent); a
  delegation never owns a ref. Ascent is value-driven: a terminating child
  detaches its escaping refs (owner→NULL) and the parent claims them by id from
  the result value it receives — no parent lookup, no entity id on the bus.
- Single `refs` table (unifies value_refs + api_files); blob refcount is kept by
  an AFTER DELETE trigger so entity CASCADE frees blobs automatically.
- Escalations are raiser-owned; the API records operator-facing ones (pending +
  answered) in run_escalations_audit, resolving the run from the bus delegation
  id on its OWN tables.
- RunState is running|cancelling|done|error (was succeeded/cancelled); the
  Haskell CLI follows.

Modules touch only their own rows on the hot path: CoreModule + ApiModule never
read another module's entity/delegation rows. FFI is entity-ized too (receiver
+ issuer): each ext call mints an ext entity that owns the refs the sidecar
produces and ascends them on terminal; ext-spawned CORE children are issued
under it. (ENV stays entity-less — a synchronous, ref-less leaf.)

Tests: runtime 94/94, api-server 60/60 (gc.integration / ffi-e2e / end-to-end),
e2e samples 31/32 (08-metadata is a pre-existing content-addressed-closure id
format issue, unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`get_metadata` on a closure returns `id = closure:<blob-hash>` — content-
addressed since the self-describing-closures change (78176bc, Phase E), where
the closure's identity is its content hash (stable across shards), not the old
ephemeral machine-local `closure:N`. The sample assertion still pinned the
pre-Phase-E `closure:0`, so it had been failing independently.

Assert the closure id's SHAPE (`closure:<64-hex>`) rather than the exact hash
(an impl detail). e2e samples now 32/32.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lined RefRep)

A closure's agent-def-id was `closureref:<full RefRep JSON>`. Slim it to just the
ref id (`closureref:<id>`): `module` is invariably `core`, and `hash`/`size`
live in the ref store keyed by that id (refs.hash) — CORE fetches the blob by
`(core, id)`, so the inlined hash/size were redundant on the wire + in the
agent_def_id column.

Converge all three on the same string (the value-codec already aimed for this):
- the closure VALUE's wire form (`$agent: closureref:<id>`),
- the delegate target,
- `get_metadata`'s `id` field (was `closure:<content-hash>` — inconsistent;
  now `closureref:<ref id>`, the dispatch handle, mirroring a top-level agent's
  `id` = its qname).

A wire-decoded / materialized closure reconstructs `{module:core, id, hash:"",
size:0}` — hash/size are vestigial for a closure (dispatch / get_metadata / GC
all key off (module, id)). e2e 08-metadata asserts the `closureref:<uuid>` shape.

Tests: runtime 94/94, api-server 60/60, e2e samples 32/32.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l-calling)

`call_agent(name, args)` now resolves a `closureref:<ref id>` name — the same
dispatch handle a closure value carries + `get_metadata` returns in its `id`.
It fetches the closure blob (its input schema lives there, not the IR),
validates args against it, and emits `delegate(closureref:<id>)` so CORE
materializes + runs the closure. This closes the AI-tool-calling round-trip for
closures: a model picks a callable by NAME → the host maps name → metadata → id
→ `call_agent(id)` (the uuid stays host-internal, never shown to the model),
exactly as for a top-level agent's qname.

`resolveTarget` is now async (the blob fetch). The dead in-shard `closure:N`
branch is removed — it was an engine-internal id, never a user-facing name (a
closure is a `closureref:<id>`).

Aligns the stale `closure:N` docs/comments (agent-def-id header, Schema.hs's
callable-ref notes, the call_agent stdlib annotation) with the real wire form
`closureref:<id>`. The compiled `$agent` schema is an open `{type: string}` —
there is no closure enumeration. New e2e sample 23-call-closure proves the
round-trip.

Tests: runtime 94/94, api-server 60/60, e2e samples 33/33.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ped handle

`PgValueStore` opened its own `this.sql.begin(...)` inside putComplete /
createFile / deleteFile / persistRef / reownRefs / sweepDetachedRefs. But
postgres.js exposes `.begin` only on the ROOT sql, NOT on a transaction-scoped
one (the callback arg of an outer `begin`), so any of these called within a
`withTransaction` threw `this.sql.begin is not a function` and 500'd the route.

This hit the file-upload route (now wrapped in a tx for the project-root entity)
and, latently, EVERY CoreModule ref-produce on Postgres (makePutRef runs inside
feed's tx) — the in-memory test backend never exercised the `.begin` path, so it
slipped through.

Add `inTx(fn)`: open a fresh tx when we hold the root sql (atomicity for direct
route calls), otherwise run `fn` directly on `this.sql` (already inside the
caller's tx, which provides atomicity). Route the six methods through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HTTP layer moved to the Entity model (RunState running|cancelling|
done|error, run-keyed escalations, an entity run-tree). Bring the local
wire mirrors + components in line with the served shapes:

- RunState is 4-state; a user cancel renders as "cancelled" via the
  run's cancelReason (passed to RunStatusBadge at every call site).
- EscalationWire is keyed by runId / escalationId (the old delegation /
  snapshot ids are gone); the escalation pages now resolve the snapshot
  through the run.
- DelegationTree{Node,Graph} -> RunTree{Node,Graph}: the graph renders the
  execution-entity forest (node id = entityId, label = module); the stale
  back-compat aliases are dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… agent ids

Three related cleanups to the value / agent model.

1. Drop `refs.origin`. Lifetime/durability now has a single source of
   truth — ownership: a durable project file is a `file` ref owned by the
   project-root entity (id = project id). `listFiles` queries by owner;
   the `origin` column + index, `RefOrigin`, and `PutInput.origin` are gone.

2. Split agent def ids into two namespaces. The EXTERNAL form
   (`qualified.name@snapshot`) is what rides the bus and lives inside an
   agent VALUE; the INTERNAL bare name is the per-snapshot IR-entries /
   sidecar-registry key. The snapshot is stamped in exactly two places —
   DelegateThread (CORE/FFI targets) and the API entry — so the
   module-level fix-up stamp (CoreModule) and the FFI ext-child re-stamp
   are removed (stampAgentDefIdSnapshot deleted). agentLiteral carries its
   snapshot (set at the source literal from the shard's snapshot, preserved
   through the codecs); get_metadata.id is the external form.

3. call_agent accepts ONLY the external id (= get_metadata.id); a bare
   name is rejected. A statically-known agent is dispatched by its
   first-class value — call_agent is only for an id that arrived as a
   string, which always carries its snapshot. Sample 22 updated to feed
   call_agent the get_metadata.id.

runtime 94/94, api-server 60/60, e2e 33/33.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A CORE-produced file (string_to_file) had no content type — the engine's
RefPutter couldn't carry one, so the data plane served it as
application/octet-stream. Thread an optional contentType through RefPutter /
StepCtx.putBlob / CoreModule.makePutRef, and have string_to_file stamp
"text/plain; charset=utf-8" (its bytes are a UTF-8 string). Upload and FFI
already declare their own content type; closures still omit it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add displayName to RefState (the data-plane `.../ref/:id/state` shape) and
have both ValueStore backends return the refs row's display_name. The wire
value of a file carries only the ref handle, so a consumer (the admin value
viewer) needs this to label / name-download a file ref — for any module
(core / ffi / api), not just persisted api uploads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make Katari's reference shapes first-class across the schema/value/invoke
surfaces instead of rendering them as raw objects:

- ValueViewer: a file ref shows its display name (fetched from the value
  state endpoint) + a download (authenticated fetch -> blob, works for any
  module; filename = display name); an agent value (`qname@snapshot`) links
  to its agent page at that snapshot; a closure / other ref renders as a ref
  chip. (Drops the stale $callable handling — the wire uses $agent.)
- AgentField: an agent-typed (`$agent`) argument is now a picker over the
  form's snapshot, building the external id `qualified.name@snapshot` (the
  front-end half of the two snapshot-stamping sites). Closures aren't
  selectable. Threaded via a SchemaForm snapshot context.
- SchemaViewer: `$agent` / `$ref` schemas show the type name (agent / file /
  ref) rather than the wire machinery.
- FilePicker: an upload prompts for an editable display name (defaulting to
  the file's name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…apshot id

Two small additions that the friendlier sidecar value API needs:

- A produced ref can carry a display name: `PutInput.displayName` flows to
  the refs row, and the produce route reads an `X-Katari-Display-Name`
  header. Lets `katari.makeFile(bytes, { name })` name an ephemeral file.
- The sidecar's env gains `KATARI_SNAPSHOT_ID` (the snapshot it runs).
  `katari.makeAgent` needs it to build the external agent id
  `qualified.name@snapshot`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…args); slim ValueClient

Give the sidecar SDK a coherent, typed surface so writing an ext module
doesn't mean hand-assembling $ref / $agent envelopes:

- Types: KatariRef<As>, KatariFile, KatariString (string | ref), KatariAgent,
  + guards (isKatariFile / isKatariString / isKatariAgent).
- Construct: katari.makeString (inline, or a ref over a size threshold),
  katari.makeFile(bytes, { name?, contentType? }), katari.makeAgent(qname)
  (= {$agent: qname@snapshot}, using katari.snapshotId).
- Read: katari.readString / katari.readBytes (inline or ref, transparently).
- katari.persist(value, { name? }).
- katari.delegate now takes a KatariAgent (or a bare id string).
- katari.agent<A>(name, handler) types the handler's args.

The old ValueClient is cut down to an internal data plane (createDataPlane:
fetchBytes / produce / persist) + the guards; the public katari.value
namespace, createValueClient, the fake streaming open(), and fetchRange are
removed. 23-blob-echo migrated to the new API (and demos typed args).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`katari apply` decoded the bundler's UTF-8 stdout with `LC8.pack` (Char8,
which truncates each char to its low byte), so any non-ASCII in the bundle —
e.g. `@katari-lang/port`'s `// ─── … ───` box-drawing comments (U+2500), or a
non-ASCII string literal — became NUL, and Postgres rejected the snapshot with
an opaque `unsupported Unicode escape sequence` 500. Decode as UTF-8 instead.
(An ext returning Japanese / emoji would have hit this too.)

Also add an `assertNoNul` guard to PgSnapshotRepo.insert naming the exact jsonb
field + offset + context, so a stray NUL surfaces precisely instead of as a
driver error deep in INSERT — that guard is what pinpointed the bug above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Result card reused the Args card's `JSON.stringify(run.args)`, so Copy JSON
yielded `{}` for a no-arg run. Copy `run.result`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A real KATARI app built to dogfood the language + SDK. examples/* is added to
the pnpm workspace and the example installs @katari-lang/port directly (so ext
types resolve; external authors install the SDK themselves). The AI slice runs
end to end: an `ai_client` (data carrying a `secret` key) provided once via a
`get_ai_client` capability, a sidecar-held conversation session, and an `infer`
agent over an `ai_infer` ext that calls Gemini.

The single self-host example is dropped — `katari init` already scaffolds a
self-host docker-compose, so README / the dev compose now point at that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ler ctx

The delegation-bound operations (`delegate`, the `make*` producers) used an
AsyncLocalStorage to recover "which delegation am I in" from the global
`katari` singleton. That breaks for event-driven exts: a `watch_*` handler
registers a listener, then the listener fires LATER off its own async chain (a
socket / emitter / timer), outside the ALS `.run()` scope — so the ambient
delegation id was null and `katari.delegate(...)` threw "must be called from
inside a katari.agent handler", silently (the throw was a swallowed floating
rejection). That's why a discord `watch_messages` would start but never deliver.

Move every per-delegation op onto the handler `ctx` (which already carried
`delegationId`): `ctx.delegate` / `ctx.makeString` / `ctx.makeFile` /
`ctx.makeAgent` / `ctx.readString` / `ctx.readBytes` / `ctx.persist` /
`ctx.snapshotId`. `ctx` is a plain object, so a handler closes over it and uses
these from any later callback — no ambient state to lose. Only `katari.agent`
(registration) stays global. `produce` takes `ownerDelegationId` explicitly;
ALS is gone. All ext call sites (discord_bot + the e2e samples) updated.

Also install a sidecar `unhandledRejection` / `uncaughtException` → stderr net:
an error escaping a tracked handler (e.g. a fire-and-forget `void ctx.delegate`
in a watch listener) has no delegation to attribute a `throw` to, so it can't
become a tree escalation — at least surface it in the logs instead of vanishing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidecar bundle is ESM, but bundled CommonJS deps (e.g. discord.js) call
`require("node:events")` at load time. esbuild leaves those as a `__require`
shim that throws "Dynamic require of … is not supported" in ESM, so the sidecar
died on start. Inject `const require = createRequire(import.meta.url)` via the
banner so the shim resolves built-ins / CJS deps at runtime. Affects any ext
with a CommonJS dependency, not just discord.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bus only logged a warn on a dropped event; the delegate / escalate / *_ack
flow between CORE and FFI was invisible. Log each drained event at debug with
{from, to, kind, delegationId?, escalationId?, agentDefId?} so KATARI_LOG_LEVEL=debug
shows the cross-module traffic — which is how you see where an event-driven run
stalls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bin.ts read the unprefixed LOG_LEVEL, but every .env template documents
KATARI_LOG_LEVEL — so the documented knob did nothing (and `pnpm dev`, which
reads .env directly, never saw a level). Read only KATARI_LOG_LEVEL and pass it
straight through in the compose files / Dockerfile / `katari init` template.
(PORT and DATABASE_URL keep their unprefixed names on purpose — PaaS-injected
PORT, the de-facto Postgres DATABASE_URL — a log level has no such convention.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Discord layer on top of the AI slice: `create_discord_client` (bot
token as a secret), `watch_messages` (delegates a handler agent per message in
a channel; never-returns, disconnects on cancel via the abort signal), and
`send_message`. `main` takes the channel id as an arg and serves it. The
capabilities (get_ai_client / get_discord_client) flow into the ext-delegated
handler automatically. Real Gemini reply posted back to the channel — verified
apply → run → live message → reply. Pulls in discord.js; adds a tsconfig so the
ext type-checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refactor the discord bot to the request/handle model. The ext primitives drop
to internal callable-taking forms (discord_watch / discord_send, client passed
explicitly); the ktr wraps them as capability agents: watch_messages(channel_id)
with on_message, get_discord_client and send_message(channel_id, text) with
get_discord_client. watch_messages raises an on_message(text, channel_id)
request per message; main installs handle { request on_message(...) { infer;
send_message; next } } and provides the capabilities.

Two payoffs: the watch signature no longer enumerates the reaction's effects
(they live in the user's handler), and the session is opened once at the top, so
the bot keeps conversation history across messages (verified end to end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A git-tracked home for the language / SDK / tooling gaps surfaced while
dogfooding, with priority tags. Replaces the scattered notes; the sample-era
gaps it would have listed are all resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a tool-calling slice to the discord_bot example. run_python is a Katari
agent over an e2b_exec ext (runs Python in an e2b sandbox); ask() reads its
schema via get_metadata and hands it to infer_with_tools, an ext that runs the
tool-call feedback loop: declare the tool to Gemini, on a functionCall dispatch
it with ctx.delegate (so the tool is a real agent — it uses the get_e2b_key
capability), feed the functionResponse back, repeat until the model answers.
A standalone solve() entry runs it from the CLI (no Discord). Verified end to
end (model wrote + ran Python, result confirmed via the bus delegate log).

The loop is written list-shaped with a single tool, so v1 = pass an array of
tools (blocked on language-side list ops). The Gemini call strips JSON-Schema
keywords its parameters proto rejects (additionalProperties, …); runtime arg
validation still uses the full schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_metadata's input schema is draft-07 (right for runtime validation) but not
directly consumable by LLM providers — Gemini rejects additionalProperties etc.
Note that per-provider schema adaptation needs a home as tool calling matures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yukikurage
yukikurage merged commit ace4e95 into main Jun 1, 2026
2 of 3 checks passed
@yukikurage
yukikurage deleted the refactor/v0.1.0-runtime-rewrite branch June 1, 2026 18:04
yukikurage added a commit that referenced this pull request Jul 1, 2026
Address the xhigh review of the http/io/env phase. Runtime + compiler.

Shared external-call reactor (review #7 / #9):
- Extract the ffi/http callee-call lifecycle into a new `ExternalCallReactor`
  base (react / complete / afterCommit / persist / load / reset, the running →
  cancelling → awaitingAnswer state machine, and the caller the reply routes to).
  FfiReactor and HttpReactor become thin: a per-call payload plus how to
  dispatch / abort and read / write their ext row. The core/api reactors keep
  their engines, so this is a call-reactor base, not folded into the root.
- The caller reactor is now owned + persisted uniformly by the base (http gains
  a `caller_reactor` column, migration 0012); HttpReactor no longer hardcodes
  `core` on recovery.
- HttpReactor lifts a result through the shared `jsonToValue` codec (drops the
  duplicated, coercing `httpResponseValue`).

Cancelling recovery, uniform + correct (review #8 / #2 / #11):
- Recovery is uniform in the base: a running call re-dispatches, a cancelling
  call re-aborts, an awaitingAnswer call waits. http no longer reaches a
  terminateAck via a redispatch error — it aborts, like ffi.
- A transport `abort` with no live request now synthesises a `cancelled`
  (FetchHttpTransport, SnapshotFfiTransport, InProcessFfiTransport), so a
  cancelling call recovered after a crash is confirmed instead of hanging.

Smaller fixes:
- http.fetch sends an explicit empty body for body-carrying methods (#4).
- env get_all / readPublic build records on a null-prototype map so an env key
  named `__proto__` is a real field, not a silent drop (#6).
- env.set refreshes `updated_at` on overwrite (onConflictDoUpdate does not fire
  the column's `$onUpdate`).
- Compiler rejects an unknown `from "reactor"` name (K3018) instead of a silent
  runtime fallback to ffi (#1).
- `asEffectMetavar` excludes io, so a bare effect metavar is matched exactly (#10).
- `signatureValueScheme` sets io through a `withIo` helper, not a hand-written
  record update that re-derives the lattice join (#12).
- `reactivate` loads the reactors' disjoint state concurrently (#13).

Restore FetchHttpTransport unit coverage (request building, GET no body, empty
body sent, non-2xx = result, at-most-once redispatch, abort → cancelled) and add
http cancelling + cancelling-recovery e2e tests. compiler 490, runtime 95 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant