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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ interface Module {
}
```

An `EventResponse` can add/edit/remove messages, request inference, signal tool changes, and atomically update module state:
An `EventResponse` can add/edit/remove messages, update module state, request inference, and signal tool changes. The framework applies its message changes, then its state update, then its inference request; when one of these throws, what came before it stays applied, that step may have taken effect in part, and the rest of that response isn't:

```typescript
return {
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/deferral-found-later.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- A deferred write that throws after it's stored, whose store can't be read back to tell whether it landed, is now looked up by its id at the boundary that wrote it, so it counts as written there, where it landed, and at a tool boundary it's injected with what that boundary stores, before the turn's later replies. Before, it was queued as a doubt and counted at a later flush where it first landed, ahead of later replies whose requests never held it. That's still so, as a known limit, for a write that can be neither read back nor looked up there: on an account where the provider enforces its binding of signed thinking to the prefix it was made with, a later request replaying the signed thinking of a reply stored after it, whose request didn't carry it, is refused with a 400 (context-manager #155). A recompile before the next tool boundary, such as a retry, carries such a copy where it was stored, and the boundary that then finds it injects it again, as before, so a later request replaying the signed thinking of a reply made after that boundary is refused the same way.
- Looking a held write up by its id no longer fails because another message's blob is missing: where context-manager's query throws, the branch is scanned without blobs, and only the rows that match are read with theirs. Before, every such write stayed a doubt, holding what was queued behind it for its agent, and each read logged it again. The lines saying a store can't be searched, and that a drain holds what's queued behind such a write, are now said once per write until a lookup succeeds. The first is said again when its failure changes, and names the query's own error, which names the missing blob.
- A module's `onProcess` response that throws as the framework applies it (at an edit of a held write that may already be stored while its store can't be searched, an edit of a message the store doesn't hold, or any store error) no longer ends the event's processing. The response stops where it threw, as before: what came before the throw stays applied, the operation that threw may have taken effect in part or whole, and the rest of it, its state update and inference request included, isn't. A line now names the module and where it threw, `process:completed` and the process log name the response with where it threw (`ModuleProcessResponse.threw`), and the other modules' responses, a `tool-call` event's dispatch, the event's trace and its process log go on. Before, the throw escaped the event's processing and all of these were lost. `EventResponse`'s docs, the README and two comments no longer say a response is applied atomically, which it wasn't: they say it's applied in order, up to a throw.
- The log line for a held write settled through a stored row a module names says when that row is one shard of a body, rather than that the whole write was stored.
281 changes: 221 additions & 60 deletions src/framework.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/module-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ export class ModuleRegistry {

/**
* Set state for a module by name.
* Used by the framework to apply stateUpdate from EventResponse atomically.
* Used by the framework to apply stateUpdate from EventResponse after its
* message changes.
*/
setModuleState(moduleName: string, state: unknown): void {
const ctx = this.moduleContexts.get(moduleName);
Expand Down
2 changes: 1 addition & 1 deletion src/modules/discord/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ export class DiscordModule implements Module {
const stored = this.storedRow(msg.discordMessageId);
if (live) live.phase = 'writing';

// Return declarative response - framework applies all writes atomically
// Return declarative response - the framework applies its writes
return {
...(stored ? {} : {
addMessages: [
Expand Down
7 changes: 7 additions & 0 deletions src/types/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export type { TraceEvent, TraceEventListener } from './trace.js';
export interface ModuleProcessResponse {
moduleName: string;
response: EventResponse;
/**
* Set when applying the response threw: the operation it threw at, such
* as `the edit of <id>`, and the error's message. What the response asked
* for before that was applied, that operation may have taken effect in
* part or whole, and the rest of it wasn't (EventResponse).
*/
threw?: { at: string; error: string };
}

/**
Expand Down
16 changes: 12 additions & 4 deletions src/types/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,15 @@ export interface AgentInfo {
export type AgentStatus = 'idle' | 'inferring' | 'streaming' | 'waiting_for_tools' | 'ready';

/**
* Response from a module's event handler.
* Response from a module's event handler. The framework applies it in this
* order: its messages added, edited and removed, then its stateUpdate, then
* its inference request. When one of these throws, such as an edit of a
* message the store doesn't hold, what came before it stays applied, the
* operation that threw may have taken effect in part or whole (a store can
* throw after it wrote), the rest of the response isn't, and a line names
* the module and where it threw; the event's other responses, a tool call's dispatch and its trace
* go on. Content the framework finds malformed refuses the whole response
* before any of it applies.
*/
export interface EventResponse {
/**
Expand Down Expand Up @@ -546,9 +554,9 @@ export interface EventResponse {
toolsChanged?: boolean;

/**
* Module state update. Applied atomically with message operations.
* The framework will call setState() with this value after applying
* message changes, ensuring consistent state.
* Module state update. The framework calls setState() with this value
* after applying the response's message changes, and not when one of
* them threw.
*/
stateUpdate?: unknown;
}
Expand Down
1 change: 1 addition & 0 deletions test/deferred-malformed-unknown-landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function harness() {
const queued: any[] = [];
const outcomes: any[] = [];
f.primaryAgentName = 'a';
f.stillUnknownSaid = new Set();
f.deliveryAgent = () => agent;
f.storedCountOf = () => count;
f.storedCopiesOf = () => readback;
Expand Down
Loading
Loading