@mozaik-ai/core 4.0.0-beta.9 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,319 +55,355 @@ DeepSeek models run through the OpenAI-compatible chat-completions endpoint, so
55
55
 
56
56
  ---
57
57
 
58
- ## The agentic environment
59
-
60
- `AgenticEnvironment` is where everything happens. `Participant`s `join()` it, and from that moment on they can **listen to messages and events** flowing through the environment by overriding any of the handlers below:
61
-
62
- | Handler | Triggered when… |
63
- | ------------------------------ | ------------------------------------------------------ |
64
- | `onJoined` | this participant joins an environment |
65
- | `onLeft` | this participant leaves an environment |
66
- | `onParticipantJoined` | another participant joins the same environment |
67
- | `onParticipantLeft` | another participant leaves the same environment |
68
- | `onMessage` | any participant sends a message |
69
- | `onFunctionCall` | its own inference returns a function call |
70
- | `onExternalFunctionCall` | another agent's inference returns a function call |
71
- | `onFunctionCallOutput` | its own function call runner returns a result |
72
- | `onExternalFunctionCallOutput` | another agent's function call runner returns a result |
73
- | `onReasoning` | its own inference returns a reasoning item |
74
- | `onExternalReasoning` | another agent's inference returns a reasoning item |
75
- | `onModelMessage` | its own inference returns an assistant message |
76
- | `onExternalModelMessage` | another agent's inference returns an assistant message |
77
- | `onInternalEvent` | its own inference emits a semantic stream event |
78
- | `onExternalEvent` | another participant emits a semantic stream event |
79
- | `onError` | one of its own handlers throws |
80
- | `onParticipantError` | another participant's handler throws |
81
-
82
- Every handler defaults to a no-op on `BaseParticipant` — override only the ones you care about.
83
-
84
- ```mermaid
85
- flowchart LR
86
- Human[Participant] -->|"sendMessage(env, text, caller)"| Env(("AgenticEnvironment"))
87
- Agent[Participant] -->|"runInference / executeFunctionCall"| Env
88
- Observer[Participant] -->|join| Env
89
- Env -->|"onMessage / onExternal*"| Human
90
- Env -->|"onFunctionCall / onReasoning / …"| Agent
91
- Env -->|"onExternal*"| Observer
92
- Env -->|"onJoined / onLeft / onParticipant*"| All
93
- ```
58
+ ## Table of contents
59
+
60
+ - [Runtime](#runtime)
61
+ - [Runtime state](#runtime-state)
62
+ - [Participants](#participants)
63
+ - [Concurrent agents](#concurrent-agents)
64
+ - [Semantic events](#semantic-events)
65
+ - [Situation handlers](#situation-handlers)
66
+ - [The agent loop](#the-agent-loop)
67
+ - [Interception](#interception)
68
+ - [Examples](#examples)
69
+ - [Made with Mozaik](#made-with-mozaik)
70
+ - [Contributing](#contributing)
94
71
 
95
72
  ---
96
73
 
97
- ## Non-blocking participants
74
+ ## Runtime
98
75
 
99
- A participant is any subclass of `Participant`. Use `BaseParticipant` as a base when you only want to override a few handlers — every handler it defines is a no-op. The _role_ (human, agent, observer) is just which capability functions a participant calls and which handlers it overrides:
100
-
101
- | Role | How to build it |
102
- | -------- | --------------------------------------------------------------------------- |
103
- | Human | A participant that calls `sendMessage(environment, text, caller)` |
104
- | Agent | A participant that calls `runInference(...)` and `executeFunctionCall(...)` |
105
- | Observer | A participant that only overrides handlers and never runs inference |
76
+ The **runtime** is the shared space where participants meet, events are published, and agent loops run. Create one with `defineRuntime`, then call `initializeRuntime` once with a `RuntimeState`:
106
77
 
107
78
  ```ts
108
- import {
109
- AgenticEnvironment,
110
- BaseParticipant,
111
- ModelContext,
112
- UserMessageItem,
113
- runInference,
114
- sendMessage,
115
- } from "@mozaik-ai/core"
79
+ import { defineRuntime, RuntimeState } from "@mozaik-ai/core"
116
80
 
117
- const environment = new AgenticEnvironment()
81
+ class AppState extends RuntimeState {}
118
82
 
119
- const human = new BaseParticipant()
83
+ const { initializeRuntime, resolveRuntime, resolveParticipant, join, leave, sendMessage, sendEvent, runLoop } =
84
+ defineRuntime<AppState>()
120
85
 
121
- class Agent extends BaseParticipant {
122
- private readonly context = ModelContext.create("demo")
86
+ initializeRuntime({ state: new AppState() })
87
+ ```
123
88
 
124
- async onMessage(message: string): Promise<void> {
125
- this.context.addContextItem(UserMessageItem.create(message))
126
- runInference({ model: "gpt-5.4", context: this.context, caller: this, environment })
127
- }
89
+ `defineRuntime` returns the functions you use for the rest of the session. They are **not** top-level package exports — keep them in module scope (or re-export them yourself):
90
+
91
+ | Function | Role |
92
+ | ----------------------------------------------------------------- | --------------------------------------------- |
93
+ | `initializeRuntime({ state, inferenceRunnerConfig? })` | Create the runtime. Throws if called twice. |
94
+ | `resolveRuntime()` | The initialized runtime (including `.state`). |
95
+ | `resolveParticipant(id)` | Look up a joined participant by id. |
96
+ | `join(participant)` / `leave(participant)` | Membership. |
97
+ | `sendMessage(message, senderId)` | Publish a `message.sent` event. |
98
+ | `sendEvent(event, senderId)` | Publish any `SemanticEvent`. |
99
+ | `runLoop(agentId, message, inferenceInput, interceptionHandler?)` | Start an agent loop. |
100
+
101
+ Calling `initializeRuntime` a second time throws `"Runtime already initialized"`. Calling any of the other functions before `initializeRuntime` throws `"Runtime not initialized"`.
102
+
103
+ ---
104
+
105
+ ## Runtime state
106
+
107
+ **Runtime state** is the typed store every participant shares. `RuntimeState` already holds the participant registry (`participants: Map<string, Participant>`). Subclass it for anything the team needs to share — an active goal, a work queue, a transcript buffer:
108
+
109
+ ```ts
110
+ import { defineRuntime, RuntimeState } from "@mozaik-ai/core"
111
+
112
+ class AppState extends RuntimeState {
113
+ activeGoal?: string
128
114
  }
129
115
 
130
- const agent = new Agent()
131
- const observer = new BaseParticipant()
116
+ const { initializeRuntime, resolveRuntime } = defineRuntime<AppState>()
132
117
 
133
- human.join(environment)
134
- agent.join(environment)
135
- observer.join(environment)
118
+ initializeRuntime({ state: new AppState() })
136
119
 
137
- sendMessage(environment, "Hello", human)
120
+ resolveRuntime().state.activeGoal = "ship the runtime docs"
138
121
  ```
139
122
 
140
- Participants react as soon as they `join()` agentic environment. The environment fans every item out to every subscriber synchronously and without awaiting them, so a slow listener never blocks producers or other listeners.
123
+ Participants read and write this object from situation processors (and from an `InterceptionHandler`). There is no second hidden store.
141
124
 
142
125
  ---
143
126
 
144
- ## Reactive agent
127
+ ## Participants
128
+
129
+ A **participant** is anyone who can join the runtime. Mozaik ships two factories:
145
130
 
146
- A reactive agent extends `BaseParticipant` and overrides the handlers it wants to react on. Each handler is already a no-op in the base class, so only the relevant ones need bodies. Capabilities are the free functions `runInference` and `executeFunctionCall` — the participant passes itself as `caller`:
131
+ | Role | Factory | What it carries |
132
+ | ------------ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
133
+ | **Human** | `createHuman({ name, capabilities, handlers })` | A manifest and situation handlers. Typically calls `sendMessage`. |
134
+ | **Agent** | `createAgent({ name, capabilities, instruction, tools, handlers })` | Manifest, handlers, an instruction, tools, and memory (`agent.getMemory().getContext()`). Typically starts thinking with `runLoop`. |
135
+ | **Observer** | Either factory, handlers only | Never calls `sendMessage` or `runLoop` — only reacts. |
136
+
137
+ Every participant has a **manifest** (`id`, `name`, `role`, `capabilities`) and a list of **situation handlers**. Identity is the manifest; behavior is the handlers you register — not method overrides on a base class.
147
138
 
148
139
  ```ts
149
- import {
150
- BaseParticipant,
151
- UserMessageItem,
152
- FunctionCallItem,
153
- FunctionCallOutputItem,
154
- ReasoningItem,
155
- ModelMessageItem,
156
- AgenticEnvironment,
157
- ModelContext,
158
- Tool,
159
- runInference,
160
- executeFunctionCall,
161
- } from "@mozaik-ai/core"
140
+ import { createAgent, createHuman } from "@mozaik-ai/core"
162
141
 
163
- export class ReactiveAgent extends BaseParticipant {
164
- constructor(
165
- private readonly environment: AgenticEnvironment,
166
- private readonly context: ModelContext,
167
- private readonly tools: Tool[] = [],
168
- ) {
169
- super()
170
- }
142
+ const human = createHuman({ name: "User", capabilities: [], handlers: [] })
171
143
 
172
- // A message from a human (or any other participant) → record it and think.
173
- async onMessage(message: string): Promise<void> {
174
- this.context.addContextItem(UserMessageItem.create(message))
175
- runInference({
176
- model: "gpt-5.5",
177
- context: this.context,
178
- tools: this.tools,
179
- caller: this,
180
- environment: this.environment,
181
- })
182
- }
144
+ const agent = createAgent({
145
+ name: "Assistant",
146
+ capabilities: ["inference"],
147
+ instruction: "You are a helpful teammate.",
148
+ tools: [],
149
+ handlers: [],
150
+ })
151
+ ```
183
152
 
184
- // The agent just produced a function call → execute it.
185
- async onFunctionCall(item: FunctionCallItem): Promise<void> {
186
- this.context.addContextItem(item)
187
- const tool = this.tools.find((t) => t.name === item.name)
188
- if (tool) executeFunctionCall(this.environment, item, tool, this)
189
- }
153
+ The role is which functions a participant calls and which specifications it registers. A critic that only watches `model.answer` from others is still just a participant.
190
154
 
191
- // The tool just produced an output → feed it back and run inference again.
192
- async onFunctionCallOutput(item: FunctionCallOutputItem): Promise<void> {
193
- this.context.addContextItem(item)
194
- runInference({
195
- model: "gpt-5.5",
196
- context: this.context,
197
- tools: this.tools,
198
- caller: this,
199
- environment: this.environment,
200
- })
201
- }
155
+ ---
202
156
 
203
- // Keep the local context in sync with model-emitted reasoning and replies.
204
- async onReasoning(item: ReasoningItem): Promise<void> {
205
- this.context.addContextItem(item)
206
- }
157
+ ## Concurrent agents
207
158
 
208
- async onModelMessage(item: ModelMessageItem): Promise<void> {
209
- this.context.addContextItem(item)
210
- }
211
- }
159
+ Several participants can be joined at the same time. Membership is runtime state, and every membership change is an event:
160
+
161
+ ```mermaid
162
+ flowchart LR
163
+ joinFn["join(participant)"] --> add["RuntimeState.addParticipant"]
164
+ add --> joined["publish participant.joined"]
165
+ leaveFn["leave(participant)"] --> remove["RuntimeState.removeParticipant"]
166
+ remove --> left["publish participant.left"]
167
+ joined --> all["every joined participant"]
168
+ left --> remaining["remaining participants"]
212
169
  ```
213
170
 
214
- Three things to note:
171
+ - `join(participant)` adds the participant to `RuntimeState` and publishes `participant.joined`. Joining an id that is already present is a no-op.
172
+ - `leave(participant)` removes them and publishes `participant.left` to whoever is still joined.
173
+ - Each `runLoop(agentId, ...)` creates a new loop with its own `loopId` and does **not** await it. Two agents can think, call tools, and answer at the same time.
174
+ - Events from any loop fan out to **every** joined participant. A slow processor is not a barrier: the runtime does not await `processor.apply`.
175
+ - Shared mutable data lives on your `RuntimeState` subclass. Situation processors (and interception) are how participants adapt to it.
176
+
177
+ ```ts
178
+ const observer = createHuman({ name: "Observer", capabilities: [], handlers: [] })
179
+
180
+ join(human)
181
+ join(agent)
182
+ join(observer)
215
183
 
216
- 1. The split between self handlers and `onExternal*` handlers means a participant can encode "act on my own outputs" separately from "observe others", without inspecting `source` by hand.
217
- 2. The agent never `await`s its capability calls inside the handlers — `runInference` and `executeFunctionCall` are fire-and-forget (they return `void`), so the environment keeps delivering events while inference and tool execution run in the background.
218
- 3. Behaviors compose by **reaction**, not orchestration. Add a second agent that overrides `onExternalModelMessage` and you get a critique loop. Add a `TranscriptLogger` and you get a UI stream. Neither change touches the existing participants.
184
+ sendMessage("Hello", human.getId())
185
+
186
+ leave(observer)
187
+ ```
188
+
189
+ An agent can wait until a collaborator has joined (`participant.joined`) before calling `runLoop`, or clean up shared state when someone leaves (`participant.left`).
219
190
 
220
191
  ---
221
192
 
222
- ## Streaming and semantic events
193
+ ## Semantic events
223
194
 
224
- When inference runs with streaming enabled (`streaming: true` on the `runInference` params, for a model that supports it), the runner does not wait for the full response. As the provider emits chunks, the endpoint yields **`SemanticEvent`** items (`type` + `data`) and the environment delivers each one to **every** joined participant immediately — the same fan-out as messages and context items. Participants react in real time by overriding the stream handlers; no participant needs to poll or share a callback.
195
+ The runtime is an event bus. Everything interesting is a **`SemanticEvent`**:
225
196
 
226
- The producing participant receives `onInternalEvent`; everyone else receives `onExternalEvent(source, event)`:
197
+ | Field | Meaning |
198
+ | ------------ | ------------------------------------------------------------------------ |
199
+ | `type` | String name of the event (`"message.sent"`, `"inference.completed"`, …). |
200
+ | `producerId` | Id of the participant that caused it. |
201
+ | `occurredAt` | When it was created. |
202
+ | `payload` | Typed data for that event. |
227
203
 
228
- ```ts
229
- import { BaseParticipant, Participant, SemanticEvent } from "@mozaik-ai/core"
204
+ `publish` delivers every event to every joined participant. There is no built-in “internal vs external” split — a situation specification filters on `event.type` and on `event.producerId` versus `participant.getId()`.
230
205
 
231
- // Agent that runs streaming inference — can observe its own stream chunks.
232
- export class StreamingAgent extends BaseParticipant {
233
- async onInternalEvent(event: SemanticEvent<unknown>): Promise<void> {
234
- if (event.type === "response.output_text.delta") {
235
- // e.g. keep a local buffer of partial output
236
- }
237
- }
238
- }
206
+ Participants never poll. Custom events go through `sendEvent(event, senderId)` with any `type` string.
239
207
 
240
- // Any other participant — UI, logger, second agent — reacts to another's stream.
241
- export class LiveTranscript extends BaseParticipant {
242
- async onExternalEvent(source: Participant, event: SemanticEvent<unknown>): Promise<void> {
243
- if (event.type === "response.output_text.delta") {
244
- const { delta } = event.data as { delta: string }
245
- process.stdout.write(delta)
246
- }
247
- }
248
- }
249
- ```
208
+ ### Lifecycle and messaging
250
209
 
251
- Enable streaming by passing `streaming: true` to `runInference`:
210
+ | Event | Published when | Payload |
211
+ | -------------------- | -------------------------------- | ----------------------------------------------------------- |
212
+ | `participant.joined` | `join(participant)` | Participant manifest (`id`, `name`, `role`, `capabilities`) |
213
+ | `participant.left` | `leave(participant)` | Participant manifest |
214
+ | `message.sent` | `sendMessage(message, senderId)` | `{ message: string }` |
215
+ | _custom_ | `sendEvent(event, senderId)` | Whatever you put on the event |
252
216
 
253
- ```ts
254
- runInference({ model: "gpt-5.4", context, caller: this, environment, streaming: true })
255
- ```
217
+ ### Agent loop
218
+
219
+ Producer is the agent whose loop is running:
220
+
221
+ | Event | Published when | Payload |
222
+ | -------------------------- | -------------------------------------------------- | -------------------------------------------------------- |
223
+ | `context_update.started` | The loop begins appending the user message | Received message plus `loopId` |
224
+ | `context_update.completed` | Context is ready for inference | `InferenceInput` plus `loopId` |
225
+ | `inference.started` | The model call begins | `InferenceInput` |
226
+ | `inference.stream` | Each streaming chunk (only when `streaming: true`) | The inner provider event |
227
+ | `inference.completed` | The model call finished | `InferenceOutput` (`items`, `tokenUsage`, `rowResponse`) |
228
+ | `function_call.started` | A tool is about to run | `{ call, inferenceInput }` |
229
+ | `function_call.completed` | The tool returned | `FunctionCallOutputItem` |
230
+ | `model.answer` | The assistant message is committed | `{ answer: ModelMessageItem }` |
231
+ | `interception.started` | An `InterceptionHandler` matched a transition | The pending `LoopTransition` |
232
+ | `interception.finished` | The handler returned (possibly rewritten) | The transition that will execute |
233
+
234
+ ### Streaming
256
235
 
257
- Requesting streaming for a model whose specification has `supportsStreaming: false` fails request validation before the API is called.
236
+ Pass `streaming: true` on the `InferenceInput` you give `runLoop`. The loop takes the `inference_streaming` path and publishes each provider chunk as `inference.stream` (the inner event is the payload). Requesting streaming for a model whose specification has `supportsStreaming: false` fails validation before the API is called.
258
237
 
259
238
  ---
260
239
 
261
- ## Lifecycle hooks
240
+ ## Situation handlers
262
241
 
263
- Every participant receives lifecycle notifications when it or others join/leave an environment:
242
+ Participants **react** by registering situation handlers. The runtime publishes events; a handler is a pair of “when” and “then”:
243
+
244
+ - **`SituationSpecification`** — `isSatisfiedBy({ event, participant })`. Compose with `.and()`, `.or()`, and `.not()`.
245
+ - **`SituationProcessor`** — `apply(context)` is the reaction: `runLoop`, `sendMessage`, mutate runtime state, log, persist, …
246
+
247
+ ```mermaid
248
+ flowchart LR
249
+ Human[Participant] -->|"sendMessage(text, senderId)"| Runtime(("Runtime"))
250
+ Agent[Participant] -->|"runLoop"| Runtime
251
+ Observer[Participant] -->|join| Runtime
252
+ Runtime -->|"SemanticEvent"| Human
253
+ Runtime -->|"SemanticEvent"| Agent
254
+ Runtime -->|"SemanticEvent"| Observer
255
+ ```
256
+
257
+ Fan-out is synchronous and does not await processors, so a slow listener never blocks producers or other listeners. Participants start receiving events as soon as they `join()`.
258
+
259
+ There are no built-in specifications — write the ones you need. Self versus others is a filter on `producerId`, not a second handler API.
264
260
 
265
261
  ```ts
266
- export class TeamAgent extends BaseParticipant {
267
- // Called when this participant joins an environment.
268
- onJoined(): void {
269
- console.log("I joined the environment")
270
- }
262
+ import {
263
+ defineRuntime,
264
+ RuntimeState,
265
+ createAgent,
266
+ createHuman,
267
+ SituationSpecification,
268
+ Agent,
269
+ type SituationHandler,
270
+ type SituationContext,
271
+ } from "@mozaik-ai/core"
271
272
 
272
- // Called when this participant leaves an environment.
273
- onLeft(): void {
274
- console.log("I left the environment")
275
- }
273
+ class AppState extends RuntimeState {}
276
274
 
277
- // Called when another participant joins the same environment.
278
- onParticipantJoined(participant: Participant): void {
279
- console.log(`${participant.constructor.name} joined`)
280
- }
275
+ const { initializeRuntime, join, sendMessage, runLoop } = defineRuntime<AppState>()
281
276
 
282
- // Called when another participant leaves the same environment.
283
- onParticipantLeft(participant: Participant): void {
284
- console.log(`${participant.constructor.name} left`)
277
+ initializeRuntime({ state: new AppState() })
278
+
279
+ class WhenOthersSendAMessage extends SituationSpecification {
280
+ isSatisfiedBy({ event, participant }: SituationContext): boolean {
281
+ return event.type === "message.sent" && event.producerId !== participant.getId()
285
282
  }
286
283
  }
287
- ```
288
284
 
289
- This lets participants react to membership changes — for example, an agent could start inference only after a required collaborator has joined, or clean up shared state when someone leaves.
285
+ const thinkOnMessage: SituationHandler = {
286
+ specification: new WhenOthersSendAMessage(),
287
+ processor: {
288
+ apply({ event, participant }) {
289
+ if (!(participant instanceof Agent)) return
290
+ const { message } = event.payload as { message: string }
291
+ runLoop(participant.getId(), message, {
292
+ model: "gpt-5.5",
293
+ context: participant.getMemory().getContext(),
294
+ tools: participant.getTools(),
295
+ })
296
+ },
297
+ },
298
+ }
290
299
 
291
- ---
300
+ const agent = createAgent({
301
+ name: "Assistant",
302
+ capabilities: ["inference"],
303
+ instruction: "You are a helpful teammate.",
304
+ tools: [],
305
+ handlers: [thinkOnMessage],
306
+ })
292
307
 
293
- ## Reacting to external events
308
+ const human = createHuman({ name: "User", capabilities: [], handlers: [] })
294
309
 
295
- Participants can listen to external events and react by overriding methods like `onMessage`, `onExternalFunctionCall`, `onExternalFunctionCallOutput`, `onExternalReasoning`, and `onExternalModelMessage`.
310
+ join(human)
311
+ join(agent)
296
312
 
297
- ### Selective listening
313
+ sendMessage("Hello", human.getId())
314
+ ```
298
315
 
299
- By default a participant reacts to events from every other participant. To scope a participant so it only reacts to specific participant _types_, populate its `listens` list with those classes. When `listens` is non-empty, the environment only delivers external events whose source is an instance of one of the listed classes:
316
+ An observer is the same pattern with processors that only take side actions:
300
317
 
301
318
  ```ts
302
- import { BaseParticipant } from "@mozaik-ai/core"
319
+ class WhenModelAnswers extends SituationSpecification {
320
+ isSatisfiedBy({ event }: SituationContext): boolean {
321
+ return event.type === "model.answer"
322
+ }
323
+ }
303
324
 
304
- export class Critic extends BaseParticipant {
305
- // Only react to events produced by Writer participants.
306
- protected listens = [Writer]
325
+ const transcript: SituationHandler = {
326
+ specification: new WhenModelAnswers(),
327
+ processor: {
328
+ apply({ event }) {
329
+ console.log("[model.answer]", event.producerId, event.payload)
330
+ },
331
+ },
307
332
  }
333
+
334
+ const observer = createHuman({ name: "Transcript", capabilities: [], handlers: [transcript] })
335
+ join(observer)
308
336
  ```
309
337
 
310
- ---
338
+ Three things to note:
311
339
 
312
- ## Error handling
340
+ 1. “Act on my own outputs” versus “observe others” is a specification filter on `event.producerId` versus `participant.getId()` — not a separate `onExternal*` API.
341
+ 2. Do not `await` `runLoop` or `sendMessage` inside a processor. They return `void` and keep running in the background while the runtime keeps delivering events.
342
+ 3. Behaviors compose by **reaction**, not orchestration. Add a second agent whose spec matches `model.answer` and you get a critique loop. Add a transcript observer and you get a UI stream. Neither change touches the existing participants.
313
343
 
314
- When any handler throws, the environment catches it and routes it as an `AgenticError` instead of crashing the run. The participant whose handler threw receives `onError(error)`; every other participant receives `onParticipantError(source, error)`. After its own `onError`, the failing participant is marked inactive in that environment so it stops receiving further events.
344
+ ---
315
345
 
316
- ```ts
317
- import { BaseParticipant, Participant, AgenticError } from "@mozaik-ai/core"
346
+ ## The agent loop
318
347
 
319
- export class ResilientAgent extends BaseParticipant {
320
- onError(error: AgenticError): void {
321
- console.error("my handler threw:", error.message)
322
- }
348
+ `runLoop(agentId, message, inferenceInput, interceptionHandler?)` drives one agent turn as a state machine. It starts at `context_update` and runs until `idle`. Tool execution and the follow-up inference live inside the loop — you do not call a separate function-call runner.
323
349
 
324
- onParticipantError(source: Participant, error: AgenticError): void {
325
- console.warn(`${source.constructor.name} failed:`, error.message)
326
- }
327
- }
350
+ Each step: optionally intercept the pending transition, execute the state, then resolve the next transition.
351
+
352
+ ```mermaid
353
+ flowchart TD
354
+ Start([runLoop]) --> CU[context_update]
355
+ CU --> INF[inference]
356
+ INF -->|function_call item| FC[function_call]
357
+ INF -->|assistant message| MM[model_message]
358
+ FC --> INF
359
+ MM --> Idle[idle]
328
360
  ```
329
361
 
330
- `AgenticError` carries the originating participant (`getSource()`) and environment (`getEnvironment()`).
362
+ | State | What it does |
363
+ | --------------------- | ------------------------------------------------------------------------------------------------ |
364
+ | `context_update` | Appends the user message to the agent's context. |
365
+ | `inference` | Calls the model and waits for the full response. |
366
+ | `inference_streaming` | Same call, streaming; each chunk is published as `inference.stream`. |
367
+ | `function_call` | Runs the matching tool, writes the call and output back into context, then returns to inference. |
368
+ | `model_message` | Publishes `model.answer`, then the loop goes `idle`. |
369
+
370
+ `runLoop` is fire-and-forget. Each invocation gets a unique `loopId`, so concurrent agents (or two loops on the same agent) do not share a cursor.
331
371
 
332
372
  ---
333
373
 
334
- ## Passive observer
374
+ ## Interception
335
375
 
336
- You can create observers that don't run inference themselves but watch what's happening in the conversation and take side actions (logging, metrics, persistence, etc.). Subclass `BaseParticipant` and override only the handlers you care about — everything else stays a no-op:
376
+ An **`InterceptionHandler`** inspects or rewrites a loop transition **before** that state runs. Pass it as the optional fourth argument to `runLoop`:
337
377
 
338
378
  ```ts
339
- import {
340
- BaseParticipant,
341
- Participant,
342
- FunctionCallItem,
343
- FunctionCallOutputItem,
344
- ReasoningItem,
345
- ModelMessageItem,
346
- } from "@mozaik-ai/core"
347
-
348
- export class TranscriptLogger extends BaseParticipant {
349
- async onMessage(message: string): Promise<void> {
350
- console.log("[message]", message)
351
- }
352
-
353
- async onExternalFunctionCall(source: Participant, item: FunctionCallItem): Promise<void> {
354
- console.log(`[${source.constructor.name}] function_call`, item)
355
- }
356
-
357
- async onExternalFunctionCallOutput(source: Participant, item: FunctionCallOutputItem): Promise<void> {
358
- console.log(`[${source.constructor.name}] function_call_output`, item)
359
- }
379
+ interface InterceptionHandler {
380
+ isSatisfiedBy(transition: ExecutableTransition): boolean
381
+ handle(transition: ExecutableTransition): Promise<ExecutableTransition>
382
+ }
383
+ ```
360
384
 
361
- async onExternalReasoning(source: Participant, item: ReasoningItem): Promise<void> {
362
- console.log(`[${source.constructor.name}] reasoning`, item)
363
- }
385
+ When `isSatisfiedBy` is true, the loop publishes `interception.started`, awaits `handle` (which may change `nextStateId` and `input`), publishes `interception.finished`, then executes the returned transition.
364
386
 
365
- async onExternalModelMessage(source: Participant, item: ModelMessageItem): Promise<void> {
366
- console.log(`[${source.constructor.name}] model_message`, item)
367
- }
387
+ ```ts
388
+ import { type InterceptionHandler } from "@mozaik-ai/core"
389
+
390
+ const inspectFunctionCalls: InterceptionHandler = {
391
+ isSatisfiedBy(transition) {
392
+ return transition.nextStateId === "function_call"
393
+ },
394
+ async handle(transition) {
395
+ if (transition.nextStateId === "function_call") {
396
+ console.log("about to call", transition.input.call.name)
397
+ }
398
+ return transition
399
+ },
368
400
  }
401
+
402
+ runLoop(agent.getId(), message, inferenceInput, inspectFunctionCalls)
369
403
  ```
370
404
 
405
+ Use it to steer loop execution by rewriting the next state — for example, swapping a tool call’s input — without putting that logic inside the agent’s situation handlers. That is how you keep a human in the loop, or another agent, to control what runs next.
406
+
371
407
  ---
372
408
 
373
409
  ## Examples
@@ -382,7 +418,7 @@ Working examples are available here: [mozaik-examples](https://github.com/jigjoy
382
418
 
383
419
  ```mermaid
384
420
  flowchart LR
385
- Conductor[Conductor] -->|"RunStart / LevelCompute / StorySpawn"| Bus(("Mozaik Bus"))
421
+ Conductor[Conductor] -->|"RunStart / LevelCompute / StorySpawn"| Bus(("Mozaik Runtime"))
386
422
  Factory[StoryFactory] -->|"spawn StoryAgent"| Bus
387
423
  Story[StoryAgent] -->|"StoryResult / retries"| Bus
388
424
  Librarian[Librarian] -->|"index exploration outputs"| Bus