Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/create-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ on:
- provider-anthropic
- provider-openai
- session-manager
- telegram-bot
- shell
- storage
bump:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ on:
- 'provider-anthropic/v*'
- 'provider-openai/v*'
- 'session-manager/v*'
- 'telegram-bot/v*'
- 'shell/v*'
- 'storage/v*'
workflow_dispatch:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ npx skills add iii-hq/iii --all
| [`codex`](codex/) | Rust | OpenAI Codex as an iii worker — `codex::*` spawn the codex CLI for headless turns, mirror raw thread events onto `codex::events`, and stream AgentEvent frames onto `agent::events`. |
| [`claude-code`](claude-code/) | Node | Claude Code as an iii worker — `claude::*` runs headless Claude Code turns, mirrors raw messages onto `claude::events`, and streams AgentEvent frames onto `agent::events`. |
| [`session-manager`](session-manager/) | Rust | Durable, reactive, branching conversation store — fourteen `session::*` functions plus six trigger types; the transcript backend for `harness` and `console`. See [`session-manager/architecture/`](session-manager/architecture/). |
| [`telegram-bot`](telegram-bot/) | Rust | Telegram webhook bridge to the harness stack — live message edits, inline approval keyboards, and configurable verbosity. |
| [`context-manager`](context-manager/) | Rust | Model-ready context assembly — four `context::*` functions for token counting, function-result pruning, and history compaction over caller-supplied messages. Storage-agnostic; summarisation via `llm-router` when installed. |
| [`database`](database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. |
| [`iii-directory`](iii-directory/) | Rust | Engine introspection (functions / triggers / workers), workers-registry proxy, and filesystem-backed skill + prompt reader. |
Expand Down
2 changes: 1 addition & 1 deletion console/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

398 changes: 0 additions & 398 deletions harness/architecture/integration.md

This file was deleted.

126 changes: 126 additions & 0 deletions harness/skills/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
name: harness
description: >-
The durable agent turn loop — kick off a turn with `harness::send` or
`harness::run`, render it from session-manager transcript events, react to
`harness::turn-completed`, with deny-by-default tool dispatch and synchronous
hook extension points for policy siblings.
---

# harness

The harness is the durable turn loop that wires `session-manager`, `llm-router`,
and `context-manager` into an agent. A consumer stays thin: it kicks off a turn,
renders the conversation from the session transcript, and reacts to turn
boundaries and human-gated calls. The harness streams the assistant message into
the session as it generates, so you watch the session, not the harness — there
is no `agent::events` stream, and `harness::status` is a point-in-time recovery
read, not a render feed.

Every invocation is a trigger (`iii.trigger({ function_id, payload })`); there is
no separate "call" verb. Tool dispatch is deny-by-default: a send with no
`options.functions.allow` is a plain chat loop and every model-requested call is
refused until you allow globs per send. Sessions are minted by the harness
(`s_<uuid>`) or supplied by you; a send into a running turn folds in as steering
(`merged: true`) instead of erroring, and a repeated `idempotency_key` returns
the original turn without appending.

Prerequisites: `session-manager` (required — transcript store and change feed)
and `llm-router` (required — generation and the model catalog) must be present.
`context-manager` (token budgeting and compaction) is a soft dependency — absent
it, the harness sends raw history. `approval-gate` (the human-in-the-loop gate)
is optional; without it no call is held and every allowed call runs un-gated.

## When to Use

- Start or steer an agent turn and return immediately (`harness::send`).
- Call an agent like a function, held open until the turn ends with the result
returned inline and an optional output contract (`harness::run`).
- Cancel an in-flight turn (`harness::stop`) or read coarse turn state for
recovery and guards (`harness::status`).
- Chain turns or react to outcomes by binding `harness::turn-completed`.
- Drive a turn from an arbitrary inbound event (cron tick, webhook, sensor) by
translating it into a `harness::send`.

## Boundaries

- Not a transcript feed. Render from `session-manager`'s `session::message-added`
/ `message-updated` / `status-changed` (reconcile by `revision`); do not poll
`harness::status` for content.
- Not the approvals engine. The harness ships only the gate mechanics; the
policy, decision RPCs (`approval::resolve`), inbox (`approval::list-pending`),
and prompt triggers live in `approval-gate`.
- Not a chain guard. `options.max_turns` bounds a single turn, not a
send-completed-send loop; carry your own stop condition.
- Do not trigger the internal functions (below) — they forge call ids and turn
progress, so calling them out of band corrupts the turn record.
- An in-run agent cannot start turns: `send` / `run` / `turn` / `stop` are denied
to the model by policy. `harness::spawn` is the only model-reachable way to
start a new turn, and it self-enforces depth, fan-out, and policy subsetting.

## Functions

Consumer-facing:

- `harness::send` — ensure the session, persist the incoming message, and kick
off a turn; returns fast or merges into a running turn (steering).
- `harness::run` — `send` held open until the turn ends; returns the turn result.
The backend/automation entry point; supports an output contract.
- `harness::stop` — request cancellation of an in-flight turn; cascades to
spawned children.
- `harness::status` — read the current turn state for a session; `null` when no
turn ever ran. For recovery and guards, not rendering.
- `harness::spawn` — spawn a sub-agent in a child session. Model-facing (invoked
through `agent_trigger`), not a consumer entry point.

Internal — the harness drives these; never trigger them directly:
`harness::turn` (the durable loop step), `harness::function::trigger` /
`harness::function::resolve` (dispatch and parked-call settle),
`harness::sweep-pending` (cron expiry), and `harness::on-config-change`
(hot-reload).

## Reactive triggers

The harness emits two async turn-boundary trigger types so consumers and siblings
react without polling `harness::status`:

- `harness::turn-started` — a turn began executing (first loop step).
- `harness::turn-completed` — a turn reached a terminal status
(`completed` / `cancelled` / `failed`), carrying the result or error for
chaining, failure toasts, auto-titling, and result delivery.

Bind `turn-completed` for outcomes and to chain the next hop; bind `turn-started`
only for observability. Delivery is fire-and-forget, at-least-once, and unordered
— treat each event as an edge. Nothing replays on reconnect: re-seed with
`harness::status` and `approval::list-pending`, then rebind. Do not bind these for
live transcript rendering — that is `session-manager`'s job.

Binding `config` filters delivery by `session_id`, or by `parent_session_id` to
watch the children a turn `spawn`s.

### How to bind

1. Register a handler: `registerFunction('myapp::on-turn-done', handler)`.
2. Register the trigger:

```typescript
iii.registerTrigger({
type: 'harness::turn-completed',
function_id: 'myapp::on-turn-done',
config: { session_id: sessionId },
})
```

For the event payload shape, call `get function info` on the trigger type.

### Hooks (policy siblings only)

The harness also registers five synchronous, in-path hook trigger types:
`harness::hook::pre-turn`, `harness::hook::pre-generate`,
`harness::hook::post-generate`, `harness::hook::pre-trigger`, and
`harness::hook::post-trigger`. A bound hook runs in the turn's critical path and
the harness acts on its return value (veto / hold / mutate) under a per-binding
`timeout_ms` and `on_error` policy; `pre-trigger` / `post-trigger` bindings take a
`functions` glob list to scope which calls they gate. These are for
operator-trusted policy siblings (`approval-gate` binds `pre-trigger`); ordinary
consumers do not bind hooks.
7 changes: 7 additions & 0 deletions iii-permissions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ rules:
- '!harness::sweep-pending'
- '!context::on-config-change'
- '!approval::on-config-change'
- '!telegram-bot::on-config-change'
- '!telegram-bot::on-message-added'
- '!telegram-bot::on-message-updated'
- '!telegram-bot::on-status-changed'
- '!telegram-bot::on-turn-completed'
- '!telegram-bot::on-pending-created'
- '!telegram-bot::on-pending-resolved'

# Read-only / introspection (extend below for your tools).
- state::get
Expand Down
4 changes: 2 additions & 2 deletions tech-specs/2026-06-agentic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ or a single worker like `llm-router` directly.
flowchart LR
%% Nodes
chat["chat"]
tg["telegram-worker"]
tg["telegram-bot"]
funcs["trigger functions<br/>as needed"]
ctx["context-manager"]
harness["harness"]
Expand Down Expand Up @@ -71,7 +71,7 @@ flowchart LR

### How to read the diagram

- **Green** (`chat`, `telegram-worker`, `third-party-worker`) are *example consumers*. They are not
- **Green** (`chat`, `telegram-bot`, `third-party-worker`) are *example consumers*. They are not
part of this spec; they show who calls in and how. Any worker or client can take their place.
- **Red** (`context-manager`, `session-manager`, `llm-router`, `harness`) are the four workers this
spec defines. Each is **standalone**: installable and useful on its own, with no hard dependency on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface MapEdge {

const NODES: MapNode[] = [
{ id: 'chat', x: 30, y: 60, w: 180, h: 56, title: 'chat', sub: 'console web app', kind: 'consumer' },
{ id: 'telegram-worker', x: 30, y: 185, w: 180, h: 56, title: 'telegram-worker', sub: 'webhook bridge', kind: 'consumer' },
{ id: 'telegram-bot', x: 30, y: 185, w: 180, h: 56, title: 'telegram-bot', sub: 'webhook bridge', kind: 'consumer' },
{ id: 'third-party', x: 30, y: 310, w: 180, h: 56, title: 'third-party-worker', sub: 'any worker', kind: 'consumer' },
{ id: 'session-manager', x: 425, y: 28, w: 210, h: 60, title: 'session-manager', sub: 'session::*', kind: 'core' },
{ id: 'harness', x: 425, y: 168, w: 210, h: 92, title: 'harness', sub: 'harness::* — the loop', kind: 'core' },
Expand All @@ -45,7 +45,7 @@ const NODES: MapNode[] = [
const EDGES: MapEdge[] = [
{ id: 'chat-send', from: 'chat', to: 'harness', d: 'M 210 92 C 300 92, 340 196, 425 196', label: 'harness::send', lx: 308, ly: 132, dur: 2.2 },
{ id: 'session-events', from: 'session-manager', to: 'chat', d: 'M 425 50 C 340 50, 300 76, 214 84', label: 'live session events', lx: 318, ly: 42, dur: 2.2 },
{ id: 'tg-send', from: 'telegram-worker', to: 'harness', d: 'M 210 213 C 300 213, 340 214, 425 214', label: 'harness::send', lx: 304, ly: 206, dur: 2.2 },
{ id: 'tg-send', from: 'telegram-bot', to: 'harness', d: 'M 210 213 C 300 213, 340 214, 425 214', label: 'harness::send', lx: 304, ly: 206, dur: 2.2 },
{ id: 'persist', from: 'harness', to: 'session-manager', d: 'M 460 168 L 460 92', label: 'append / stream deltas', lx: 452, ly: 136, anchor: 'end', dur: 1.6 },
{ id: 'assemble', from: 'harness', to: 'context-manager', d: 'M 540 260 L 540 326', label: 'context::assemble', lx: 548, ly: 298, anchor: 'start', dur: 1.6 },
{ id: 'generate', from: 'harness', to: 'llm-router', d: 'M 635 200 L 796 200', label: 'router::chat', lx: 712, ly: 192, dur: 1.8 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ export const WORKERS: Record<string, WorkerInfo> = {
],
notes: ['any worker or client can take this place — the surface is the contract.'],
},
'telegram-worker': {
id: 'telegram-worker',
'telegram-bot': {
id: 'telegram-bot',
kind: 'consumer',
kindLabel: 'example consumer',
role: 'a webhook bridge: telegram updates in, live message edits out.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { UseCaseShell } from './UseCaseShell'

const LANES: SeqLane[] = [
{ id: 'user', label: 'telegram user', x: 90 },
{ id: 'tg', label: 'telegram-worker', x: 300 },
{ id: 'tg', label: 'telegram-bot', x: 300 },
{ id: 'harness', label: 'harness', x: 520 },
{ id: 'session', label: 'session-manager', x: 720 },
{ id: 'router', label: 'llm-router', x: 900 },
Expand Down
1 change: 1 addition & 0 deletions telegram-bot/.iii-worker.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pid=57177
Loading
Loading