feat(agent): add Pico storage foundation

This commit is contained in:
Mario Zechner 2026-09-18 12:32:00 +02:00
parent 328926b30e
commit cf8d5fac30
25 changed files with 1115 additions and 91 deletions

View file

@ -15,16 +15,16 @@ facades, membranes, document routing, view projection, events, or clone chains.
## 1. Records, cursors, and memory tables
Implement IDs, sequences, Session metadata with stable root ID,
`ConversationRecord`, `EntryRecord`, inputs, live/terminal `TaskRecord` values,
document metadata, storage writes, cursors, and detached `MemoryStorage` tables.
Implement IDs, sequences, reserved root conversation ID `1`,
`ConversationRecord`, `EntryRecord`, tagged inputs, live/terminal `TaskRecord`
values, document metadata, storage writes, backend-opaque JSON cursors, and
detached `MemoryStorage` tables.
Reserve `Conversation` for the public conversation object, `Entry` for the typed
entry definition, and `Task` for the typed executable definition returned by
`defineTask()`.
Test atomic root-metadata/conversation creation, missing or dangling root
metadata rejection, mixed atomic commits, rollback, detached reads/writes,
cursor boundaries,
Test reserved root identity and immutable creation, mixed atomic commits,
rollback, detached reads/writes, cursor boundaries,
fork-aware entry scans through deep ancestor caps, head lookup,
entry-to-commit lookup, and full task replacement.

View file

@ -7,7 +7,7 @@ Pico5 uses existing package types as follows:
import type { Context, JsonValue } from "@earendil-works/chord";
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
import type {
Message as ModelMessage,
Message,
Models,
ModelThinkingLevel,
TextContent,
@ -66,6 +66,7 @@ these contracts.
```ts
type Id = number;
type Seq = number;
const ROOT_CONVERSATION_ID: Id = 1;
type ConversationRecord = {
readonly id: Id;
@ -78,8 +79,6 @@ type ConversationRecord = {
readonly taskId: Id;
};
};
type RequestMessage = ModelMessage;
```
The referenced pi-ai member is:
@ -105,14 +104,14 @@ order yields the effective prompt and tool set.
type ContextEdit = {
readonly target: Id;
readonly action: "omit" | "replace";
readonly messages?: readonly RequestMessage[];
readonly messages?: readonly Message[];
};
type EntryRecord = {
readonly id: Id;
readonly conversationId: Id;
readonly kind: string;
readonly model?: readonly RequestMessage[];
readonly model?: readonly Message[];
readonly data?: JsonValue;
readonly head?: Id;
readonly edits?: readonly ContextEdit[];
@ -123,16 +122,27 @@ type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head
readonly head?: Id | "self";
};
type Input = {
type InputBase = {
readonly id: Id;
readonly conversationId: Id;
readonly requestId?: string;
readonly status: "queued" | "placed" | "done" | "unanswered";
readonly entry?: Id;
readonly answer?: Id;
readonly reason?: string;
readonly detail?: JsonValue;
};
type Input = InputBase & (
| { readonly status: "queued" }
| { readonly status: "placed"; readonly entry: Id }
| {
readonly status: "done";
readonly entry: Id;
readonly answer?: Id; // absent for a completed passive write
}
| {
readonly status: "unanswered";
readonly entry?: Id; // present when placement preceded failure
readonly reason: string;
readonly detail?: JsonValue;
}
);
```
Conversation history parenting and task ownership are separate:
@ -238,7 +248,7 @@ function defineEntry<E extends EntryRecord>(kind: string): Entry<E>;
type ContextView = {
readonly head: EntryRecord | undefined;
readonly entries: readonly EntryRecord[];
readonly messages: readonly RequestMessage[];
readonly messages: readonly Message[];
};
type SettledInput = Input & {
@ -292,10 +302,10 @@ interface Conversation {
context(context: Context): Promise<ContextView>;
entries(
query: Omit<EntryQuery, "conversationId">,
cursor: EntryCursor | undefined,
cursor: Cursor | undefined,
limit: number,
context: Context,
): Promise<Page<EntryRecord, EntryCursor>>;
): Promise<Page<EntryRecord, Cursor>>;
fork(
at: Id,
spec: Omit<ConversationSpec, "parent">,
@ -374,12 +384,11 @@ settles unknown or unmigratable live kinds as `orphaned` before resolving. IDs
and names must be unique. No handler dispatches during open. Dynamic registration is available
after open and does not resurrect a task already settled by that pass.
Storage contains one stable root conversation ID in Session metadata. Empty
storage creates the conversation and metadata together from `options.root`;
reopen reads that ID rather than inferring it from a conversation scan.
`options.root` never overwrites existing state. `root()` returns the stored
handle. A conversation with no configured model produces a durable `no_model`
generation failure.
The root conversation always has reserved ID `ROOT_CONVERSATION_ID` (`1`). Empty
storage creates that conversation from `options.root`; reopen looks it up by the
reserved ID. `options.root` never overwrites existing state. `root()` returns
that handle. A conversation with no configured model produces a durable
`no_model` generation failure.
`resume()` is idempotent while running and only enables scheduling. It does not
repeat open-time reconciliation. `suspend()` is terminal for that Harness
@ -1140,7 +1149,7 @@ conversation document containing tagged items:
```ts
type InboxItem =
| { readonly id: Id; readonly mode: "steer" | "followUp"; readonly input: ModelMessage }
| { readonly id: Id; readonly mode: "steer" | "followUp"; readonly input: Message }
| { readonly id: Id; readonly mode: "write"; readonly entry: EntryDraft };
```
@ -1769,11 +1778,7 @@ type Page<T, C> = {
readonly next?: C;
};
type Cursor<K extends string> = string & { readonly __kind?: K };
type ConversationCursor = Cursor<"conversation">;
type EntryCursor = Cursor<"entry">;
type TaskCursor = Cursor<"task">;
type DocumentCursor = Cursor<"document">;
type Cursor = Readonly<Record<string, JsonValue>>;
type EntryQuery = {
readonly conversationId: Id;
@ -1810,12 +1815,7 @@ type StoredDocument = {
];
};
type SessionMetadata = {
readonly rootConversationId: Id;
};
type StorageWrite =
| { readonly type: "session.metadata"; readonly value: SessionMetadata }
| { readonly type: "conversation"; readonly value: ConversationRecord }
| { readonly type: "entry"; readonly value: EntryRecord }
| { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }
@ -1836,34 +1836,34 @@ type StorageWrite =
| { readonly type: "document.retire"; readonly id: Id };
interface Storage {
/** The owning Session serializes calls on its mutation line. */
commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
mintId(): Id;
metadata(context: Context): Promise<SessionMetadata | undefined>;
conversation(id: Id, context: Context): Promise<ConversationRecord | undefined>;
scanConversations(cursor: ConversationCursor | undefined, limit: number, context: Context): Promise<Page<ConversationRecord, ConversationCursor>>;
scanConversations(cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<ConversationRecord, Cursor>>;
entries(ids: readonly Id[], context: Context): Promise<ReadonlyMap<Id, EntryRecord>>;
scanEntries(query: EntryQuery, cursor: EntryCursor | undefined, limit: number, context: Context): Promise<Page<EntryRecord, EntryCursor>>;
scanEntries(query: EntryQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<EntryRecord, Cursor>>;
entryCommit(id: Id, context: Context): Promise<Seq | undefined>;
task(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanTasks(query: TaskQuery, cursor: TaskCursor | undefined, limit: number, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, TaskCursor>>;
scanTasks(query: TaskQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
input(id: Id, context: Context): Promise<Input | undefined>;
inputByRequest(conversationId: Id, requestId: string, context: Context): Promise<Input | undefined>;
document(id: Id, at: Seq | "current", context: Context): Promise<StoredDocument | undefined>;
scanDocuments(query: DocumentQuery, cursor: DocumentCursor | undefined, limit: number, context: Context): Promise<Page<DocumentMetadata, DocumentCursor>>;
scanDocuments(query: DocumentQuery, cursor: Cursor | undefined, limit: number, context: Context): Promise<Page<DocumentMetadata, Cursor>>;
close(context: Context): Promise<void>;
}
```
`metadata()` is absent only for empty storage. The first root creation commits
its conversation and metadata together. Existing records with missing metadata,
or metadata naming a missing conversation, make open fail rather than guessing a
root.
Cursors are backend-owned JSON objects. Callers only round-trip them to the same
scan on the same storage; cross-storage or cross-query use is unsupported. The
Session owns the mutation line, so storage implementations do not add a second
caller-facing commit mutex. Each backend still makes one admitted batch atomic.
`EntryQuery` supports conversation ancestry, a strict `before` entry cursor,
kind filtering, and `withHead`. Document queries support definition, scope,

View file

@ -181,10 +181,7 @@ export function finalizeToolCall<TContext extends object | undefined>(
}
/** Reconstruct the canonical tool result represented by a staged transcript message. */
export function toolResultFromMessage(
message: ToolResultMessage<unknown>,
terminate: boolean,
): AgentToolResult<unknown> {
export function toolResultFromMessage(message: ToolResultMessage, terminate: boolean): AgentToolResult {
return {
content: message.content,
details: message.details,
@ -200,7 +197,7 @@ export function createToolResultMessage(call: FinalizedToolCall): ToolResultMess
toolCallId: call.toolCall.id,
toolName: call.toolCall.name,
content: call.result.content ?? [],
...(call.result.details === undefined ? {} : { details: call.result.details }),
...(call.result.details === undefined ? {} : { details: call.result.details as JsonValue }),
...(call.result.usage === undefined ? {} : { usage: call.result.usage }),
isError: call.isError,
timestamp: Date.now(),

View file

@ -50,15 +50,15 @@ export async function readToolBatchSource<TContext extends object | undefined>(
type PlacementItem = {
call: Extract<ToolCall, { status: "outcome_ready" }>;
message: ToolResultMessage<unknown>;
message: ToolResultMessage;
};
type PlacementRead = {
items: PlacementItem[];
turnResults?: ToolResultMessage<unknown>[];
turnResults?: ToolResultMessage[];
};
function isToolResultMessage(value: unknown): value is ToolResultMessage<unknown> {
function isToolResultMessage(value: unknown): value is ToolResultMessage {
return typeof value === "object" && value !== null && "role" in value && value.role === "toolResult";
}
@ -107,7 +107,7 @@ async function readPlacement<TContext extends object | undefined>(
items.push({ call, message: stored.value.payload });
}
let turnResults: ToolResultMessage<unknown>[] | undefined;
let turnResults: ToolResultMessage[] | undefined;
if (first === current.calls.length) {
const placedIds = current.calls
.filter((call) => call.status === "completed")

View file

@ -36,7 +36,7 @@ import {
} from "./tool-placement.ts";
type ToolCallTask = { completion: Promise<void> };
type ToolOutcome = { toolCall: AgentToolCall; message: ToolResultMessage<unknown>; terminate: boolean };
type ToolOutcome = { toolCall: AgentToolCall; message: ToolResultMessage; terminate: boolean };
type PreparedToolInvocation<TContext extends object | undefined> =
| { kind: "ready"; cleared: ClearedToolCall<TContext> }
| { kind: "outcome"; outcome: ToolOutcome };
@ -134,13 +134,13 @@ function syntheticMessage(
toolCall: AgentToolCall,
content: AgentToolResult<unknown>["content"],
options: { details?: unknown; usage?: AgentToolResult<unknown>["usage"] } = {},
): ToolResultMessage<unknown> {
): ToolResultMessage {
return {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content,
...(options.details === undefined ? {} : { details: options.details }),
...(options.details === undefined ? {} : { details: options.details as JsonValue }),
...(options.usage === undefined ? {} : { usage: options.usage }),
isError: true,
timestamp: Date.now(),

View file

@ -0,0 +1,24 @@
export { MemoryStorage } from "./memory-storage.ts";
export type {
ContextEdit,
ConversationRecord,
Cursor,
DocumentIdentity,
DocumentMetadata,
EntryDraft,
EntryQuery,
EntryRecord,
Id,
Input,
LiveTask,
Page,
Seq,
Storage,
StorageWrite,
StoredError,
TaskOutcome,
TaskQuery,
TaskRecord,
TerminalTask,
} from "./types.ts";
export { ROOT_CONVERSATION_ID } from "./types.ts";

View file

@ -0,0 +1,249 @@
import type { Context, JsonValue } from "@earendil-works/chord";
import type {
ConversationRecord,
Cursor,
EntryQuery,
EntryRecord,
Id,
Input,
Page,
Seq,
Storage,
StorageWrite,
TaskQuery,
TaskRecord,
} from "./types.ts";
type StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;
type TableName = "conversation" | "entry" | "task" | "input";
type State = {
conversations: Map<Id, ConversationRecord>;
entries: Map<Id, EntryRecord>;
entryCommits: Map<Id, Seq>;
tasks: Map<Id, StoredTask>;
inputs: Map<Id, Input>;
};
/** Clone trusted JSON containers while sharing immutable primitives. */
const clone = <T>(value: T): T => {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map((item) => clone(item)) as T;
const source = value as Record<string, unknown>;
const nullPrototype = Object.getPrototypeOf(value) === null;
const result = (nullPrototype ? Object.create(null) : {}) as Record<string, unknown>;
for (const key of Object.keys(source)) {
const copied = clone(source[key]);
if (!nullPrototype && key in result) {
Object.defineProperty(result, key, {
value: copied,
writable: true,
enumerable: true,
configurable: true,
});
} else {
result[key] = copied;
}
}
return result as T;
};
const cursorId = (cursor: Readonly<Record<string, JsonValue>> | undefined): Id | undefined =>
cursor?.after as Id | undefined;
const tableContaining = (state: State, id: Id): TableName | undefined => {
if (state.conversations.has(id)) return "conversation";
if (state.entries.has(id)) return "entry";
if (state.tasks.has(id)) return "task";
if (state.inputs.has(id)) return "input";
return undefined;
};
const page = <T extends { readonly id: Id }>(values: readonly T[], limit: number): Page<T, Cursor> => {
const items = values.slice(0, limit);
if (values.length <= limit) return { items: clone(items) };
return { items: clone(items), next: { after: items.at(-1)!.id } };
};
export class MemoryStorage implements Storage {
private readonly state: State = {
conversations: new Map(),
entries: new Map(),
entryCommits: new Map(),
tasks: new Map(),
inputs: new Map(),
};
private nextId = 2;
private nextSeq = 1;
private closed = false;
async commit(writes: readonly StorageWrite[], _context: Context): Promise<Seq> {
this.assertOpen();
const prepared = writes.map((write) => clone(write));
this.checkImmutableIds(prepared);
const seq = this.nextSeq;
for (const write of prepared) {
switch (write.type) {
case "conversation":
this.state.conversations.set(write.value.id, write.value);
break;
case "entry":
this.state.entries.set(write.value.id, write.value);
this.state.entryCommits.set(write.value.id, seq);
break;
case "task":
this.state.tasks.set(write.value.id, write.value);
break;
case "input":
this.state.inputs.set(write.value.id, write.value);
break;
}
this.nextId = Math.max(this.nextId, write.value.id + 1);
}
this.nextSeq++;
return seq;
}
mintId(): Id {
this.assertOpen();
if (!Number.isSafeInteger(this.nextId)) throw new Error("ID space is exhausted");
return this.nextId++;
}
async conversation(id: Id, _context: Context): Promise<ConversationRecord | undefined> {
this.assertOpen();
const value = this.state.conversations.get(id);
return value === undefined ? undefined : clone(value);
}
async scanConversations(
cursor: Cursor | undefined,
limit: number,
_context: Context,
): Promise<Page<ConversationRecord, Cursor>> {
this.assertOpen();
const after = cursorId(cursor);
const values = [...this.state.conversations.values()]
.filter((value) => after === undefined || value.id > after)
.sort((left, right) => left.id - right.id);
return page(values, limit);
}
async entries(ids: readonly Id[], _context: Context): Promise<ReadonlyMap<Id, EntryRecord>> {
this.assertOpen();
const result = new Map<Id, EntryRecord>();
for (const id of ids) {
const value = this.state.entries.get(id);
if (value !== undefined) result.set(id, clone(value));
}
return result;
}
async scanEntries(
query: EntryQuery,
cursor: Cursor | undefined,
limit: number,
_context: Context,
): Promise<Page<EntryRecord, Cursor>> {
this.assertOpen();
if (!this.state.conversations.has(query.conversationId)) {
throw new Error(`Unknown conversation: ${query.conversationId}`);
}
const beforeCursor = cursorId(cursor);
const visible: EntryRecord[] = [];
let currentId = query.conversationId;
let upperEntryId = Number.POSITIVE_INFINITY;
const visited = new Set<Id>();
while (true) {
if (visited.has(currentId)) throw new Error("Conversation parent cycle");
visited.add(currentId);
for (const entry of this.state.entries.values()) {
if (entry.conversationId !== currentId || entry.id > upperEntryId) continue;
if (query.before !== undefined && entry.id >= query.before) continue;
if (beforeCursor !== undefined && entry.id >= beforeCursor) continue;
if (query.kind !== undefined && entry.kind !== query.kind) continue;
if (query.withHead === true && entry.head === undefined) continue;
visible.push(entry);
}
const conversation = this.state.conversations.get(currentId)!;
if (conversation.parent === undefined) break;
upperEntryId = Math.min(upperEntryId, conversation.parent.at);
currentId = conversation.parent.conversationId;
}
visible.sort((left, right) => right.id - left.id);
return page(visible, limit);
}
async entryCommit(id: Id, _context: Context): Promise<Seq | undefined> {
this.assertOpen();
return this.state.entryCommits.get(id);
}
async task(id: Id, _context: Context): Promise<StoredTask | undefined> {
this.assertOpen();
const value = this.state.tasks.get(id);
return value === undefined ? undefined : clone(value);
}
async scanTasks(
query: TaskQuery,
cursor: Cursor | undefined,
limit: number,
_context: Context,
): Promise<Page<StoredTask, Cursor>> {
this.assertOpen();
const after = cursorId(cursor);
const values = [...this.state.tasks.values()]
.filter((value) => after === undefined || value.id > after)
.filter((value) => query.conversationId === undefined || value.conversationId === query.conversationId)
.filter((value) => query.kind === undefined || value.kind === query.kind)
.filter((value) => query.status === undefined || value.state.status === query.status)
.filter((value) => query.abortRequested === undefined || value.abortRequested === query.abortRequested)
.filter((value) => query.background === undefined || value.background === query.background)
.sort((left, right) => left.id - right.id);
return page(values, limit);
}
async input(id: Id, _context: Context): Promise<Input | undefined> {
this.assertOpen();
const value = this.state.inputs.get(id);
return value === undefined ? undefined : clone(value);
}
async inputByRequest(conversationId: Id, requestId: string, _context: Context): Promise<Input | undefined> {
this.assertOpen();
for (const value of this.state.inputs.values()) {
if (value.conversationId === conversationId && value.requestId === requestId) return clone(value);
}
return undefined;
}
async close(_context: Context): Promise<void> {
this.closed = true;
}
private checkImmutableIds(writes: readonly StorageWrite[]): void {
const claimed = new Map<Id, TableName>();
for (const write of writes) {
const table = write.type;
const id = write.value.id;
const existing = tableContaining(this.state, id);
const earlier = claimed.get(id);
if (table === "conversation" || table === "entry") {
if (existing !== undefined) throw new Error(`ID ${id} already belongs to ${existing}`);
if (earlier !== undefined) throw new Error(`ID ${id} is written more than once`);
} else {
if (existing !== undefined && existing !== table) {
throw new Error(`ID ${id} already belongs to ${existing}`);
}
if (earlier !== undefined && earlier !== table) throw new Error(`ID ${id} is written as two record types`);
}
claimed.set(id, table);
}
}
private assertOpen(): void {
if (this.closed) throw new Error("MemoryStorage is closed");
}
}

View file

@ -0,0 +1,221 @@
import type { Context, JsonValue } from "@earendil-works/chord";
import type { Message } from "@earendil-works/pi-ai";
/** Session-global durable record identifier. */
export type Id = number;
/** Storage-assigned atomic commit sequence. */
export type Seq = number;
/** The root conversation always uses this reserved ID. */
export const ROOT_CONVERSATION_ID: Id = 1;
export type StoredError = {
readonly message: string;
readonly detail?: JsonValue;
};
export type ConversationRecord = {
readonly id: Id;
/** Transcript ancestry and the inclusive entry through which history is inherited. */
readonly parent?: {
readonly conversationId: Id;
readonly at: Id;
};
/** Durable task-ownership edge used for subtree abort and idle traversal. */
readonly owner?: {
readonly conversationId: Id;
readonly taskId: Id;
};
};
export type ContextEdit = {
readonly target: Id;
readonly action: "omit" | "replace";
readonly messages?: readonly Message[];
};
export type EntryRecord = {
readonly id: Id;
readonly conversationId: Id;
readonly kind: string;
/** Messages contributed to model context; absent for display or bookkeeping entries. */
readonly model?: readonly Message[];
readonly data?: JsonValue;
/** Entry at which the active context begins. */
readonly head?: Id;
readonly edits?: readonly ContextEdit[];
readonly byTaskId?: Id;
};
export type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
readonly head?: Id | "self";
};
type InputBase = {
readonly id: Id;
readonly conversationId: Id;
readonly requestId?: string;
};
/** Durable admission and settlement state for one host input. */
export type Input = InputBase &
(
| {
readonly status: "queued";
readonly entry?: never;
readonly answer?: never;
readonly reason?: never;
readonly detail?: never;
}
| {
readonly status: "placed";
/** User transcript entry created when this input was placed. */
readonly entry: Id;
readonly answer?: never;
readonly reason?: never;
readonly detail?: never;
}
| {
readonly status: "done";
/** User or passive-write transcript entry created when this input was placed. */
readonly entry: Id;
/** Assistant answer entry; absent for a completed passive write. */
readonly answer?: Id;
readonly reason?: never;
readonly detail?: never;
}
| {
readonly status: "unanswered";
/** Present when the input was placed before becoming unanswered. */
readonly entry?: Id;
readonly answer?: never;
readonly reason: string;
readonly detail?: JsonValue;
}
);
export type LiveTask<S> = {
readonly status: "pending" | "running";
readonly checkpoint: S;
};
export type TaskOutcome<R> =
| { readonly status: "completed"; readonly result: R }
/** Expected task/domain failure explicitly committed by its implementation. */
| { readonly status: "failed"; readonly error: StoredError; readonly result?: R }
| { readonly status: "aborted"; readonly reason?: string; readonly result?: R }
| { readonly status: "orphaned"; readonly reason: string }
/** Harness-detected contract failure, such as an uncaught throw or no durable progress. */
| { readonly status: "faulted"; readonly error: StoredError };
export type TerminalTask<R> = {
readonly status: "terminal";
readonly outcome: TaskOutcome<R>;
};
export type TaskRecord<I, S, R> = {
readonly id: Id;
readonly conversationId: Id;
readonly kind: string;
readonly version: number;
readonly input: I;
readonly state: LiveTask<S> | TerminalTask<R>;
readonly after: readonly Id[];
readonly background: boolean;
readonly abortRequested: boolean;
readonly memos?: Readonly<Record<string, JsonValue>>;
};
/** Persisted identity of one durable document incarnation. */
export type DocumentIdentity = {
readonly id: Id;
readonly definitionId: string;
readonly definitionVersion: number;
readonly instanceId?: string;
} & (
| { readonly scope: { readonly kind: "session" } }
| ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: Id } } & (
| { readonly history: "latest"; readonly fork: "current" | "initial" }
| {
readonly history: "rewindable";
readonly fork: "asOf" | "current" | "initial";
}
))
| { readonly scope: { readonly kind: "task"; readonly taskId: Id } }
);
/** Commit-sequence lifetime of one persisted document incarnation. */
export type DocumentMetadata = DocumentIdentity & {
readonly createdAt: Seq;
readonly retiredAt?: Seq;
};
export type Page<T, C> = {
readonly items: readonly T[];
readonly next?: C;
};
/** Backend-owned, JSON-serializable continuation state. Callers must treat it as opaque. */
export type Cursor = Readonly<Record<string, JsonValue>>;
export type EntryQuery = {
readonly conversationId: Id;
/** Strict semantic cutoff: only entries with IDs less than this value match. */
readonly before?: Id;
readonly kind?: string;
readonly withHead?: boolean;
};
export type TaskQuery = {
readonly conversationId?: Id;
readonly kind?: string;
readonly status?: "pending" | "running" | "terminal";
readonly abortRequested?: boolean;
readonly background?: boolean;
};
/** Package 1 table writes. Document lifecycle writes are added in Package 2. */
export type StorageWrite =
| { readonly type: "conversation"; readonly value: ConversationRecord }
| { readonly type: "entry"; readonly value: EntryRecord }
| { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }
| { readonly type: "input"; readonly value: Input };
export interface Storage {
/**
* Persist one batch atomically and return its commit sequence.
* The owning Session serializes calls on its mutation line; Storage implementations
* do not provide a second caller-facing mutation mutex.
*/
commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
mintId(): Id;
conversation(id: Id, context: Context): Promise<ConversationRecord | undefined>;
scanConversations(
cursor: Cursor | undefined,
limit: number,
context: Context,
): Promise<Page<ConversationRecord, Cursor>>;
entries(ids: readonly Id[], context: Context): Promise<ReadonlyMap<Id, EntryRecord>>;
scanEntries(
query: EntryQuery,
cursor: Cursor | undefined,
limit: number,
context: Context,
): Promise<Page<EntryRecord, Cursor>>;
entryCommit(id: Id, context: Context): Promise<Seq | undefined>;
task(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanTasks(
query: TaskQuery,
cursor: Cursor | undefined,
limit: number,
context: Context,
): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
input(id: Id, context: Context): Promise<Input | undefined>;
inputByRequest(conversationId: Id, requestId: string, context: Context): Promise<Input | undefined>;
close(context: Context): Promise<void>;
}

View file

@ -4,6 +4,7 @@ import type {
AssistantMessageEvent,
AssistantMessageEventStream,
ImageContent,
JsonValue,
Message,
Model,
SimpleStreamOptions,
@ -379,7 +380,7 @@ export interface AgentState {
}
/** Final or partial result produced by a tool. */
export interface AgentToolResult<T> {
export interface AgentToolResult<T = JsonValue | undefined> {
/** Text or image content returned to the model. */
content: (TextContent | ImageContent)[];
/** Arbitrary structured details for logs or UI rendering. */

View file

@ -1,3 +1,4 @@
import type { JsonObject } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import { BACKGROUND_CONTEXT } from "../../src/harness/context.ts";
@ -14,7 +15,7 @@ import type { AgentToolCall, AgentToolResult } from "../../src/types.ts";
const parameters = Type.Object({ value: Type.String() });
function call(arguments_: Record<string, unknown> = { value: "input" }): AgentToolCall {
function call(arguments_: JsonObject = { value: "input" }): AgentToolCall {
return { type: "toolCall", id: "call-1", name: "echo", arguments: arguments_ };
}
@ -231,6 +232,16 @@ describe("tool execution primitives", () => {
expect(message.timestamp).toBeGreaterThanOrEqual(before);
});
it("preserves unusual JSON object keys in tool-result details", () => {
const cleared = clearPrepared(prepareToolCall(call(), [tool()]));
const details = JSON.parse('{"__proto__":{"preserved":true}}') as Record<string, unknown>;
const finalized = finalizeToolCall(cleared, { result: { content: [], details }, isError: false }, undefined);
const message = createToolResultMessage(finalized);
expect(Object.hasOwn(message.details as object, "__proto__")).toBe(true);
expect(message.details).toEqual(details);
});
it("normalizes missing content from untyped tools", () => {
const cleared = clearPrepared(prepareToolCall(call(), [tool()]));
const finalized = finalizeToolCall(

View file

@ -186,7 +186,7 @@ export function fake(o: FakeOptions): Models & { calls: number; requests: Reques
type: "toolCall",
id: `call_${n}_${index}`,
name: tc.name,
arguments: tc.arguments as Record<string, unknown>,
arguments: tc.arguments as ToolCall["arguments"],
};
partial.content.push(call);
yield { type: "toolcall_start", contentIndex: index, partial };

View file

@ -0,0 +1,401 @@
import type { JsonValue } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { describe, expect, it } from "vitest";
import { MemoryStorage } from "../../src/pico/memory-storage.ts";
import {
type EntryRecord,
type Id,
type Input,
ROOT_CONVERSATION_ID,
type StorageWrite,
type TaskRecord,
} from "../../src/pico/types.ts";
const context = BACKGROUND_CONTEXT;
type StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;
async function createRoot(storage: MemoryStorage): Promise<Id> {
await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context);
return ROOT_CONVERSATION_ID;
}
function pendingTask(id: Id, conversationId: Id, phase = "ready"): StoredTask {
return {
id,
conversationId,
kind: "test.task",
version: 1,
input: { value: id },
state: { status: "pending", checkpoint: { phase } },
after: [],
background: false,
abortRequested: false,
};
}
function entry(id: Id, conversationId: Id, kind = "message", extra: Partial<EntryRecord> = {}): EntryRecord {
return { id, conversationId, kind, ...extra };
}
describe("Pico MemoryStorage", () => {
it("reserves ID 1 for the immutable root conversation", async () => {
const storage = new MemoryStorage();
expect(storage.mintId()).toBe(2);
await expect(createRoot(storage)).resolves.toBe(ROOT_CONVERSATION_ID);
expect(await storage.conversation(ROOT_CONVERSATION_ID, context)).toEqual({ id: ROOT_CONVERSATION_ID });
await expect(
storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context),
).rejects.toThrow(`ID ${ROOT_CONVERSATION_ID} already belongs to conversation`);
});
it("commits mixed table writes atomically and rolls all of them back on failure", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const entryId = storage.mintId();
const taskId = storage.mintId();
const inputId = storage.mintId();
const task = pendingTask(taskId, rootId);
const input: Input = {
id: inputId,
conversationId: rootId,
requestId: "request-1",
status: "placed",
entry: entryId,
};
const initialSeq = await storage.commit(
[
{ type: "entry", value: entry(entryId, rootId, "user", { data: { text: "hello" } }) },
{ type: "task", value: task },
{ type: "input", value: input },
],
context,
);
expect(await storage.entries([entryId], context)).toEqual(
new Map([[entryId, entry(entryId, rootId, "user", { data: { text: "hello" } })]]),
);
expect(await storage.task(taskId, context)).toEqual(task);
expect(await storage.input(inputId, context)).toEqual(input);
const transientEntryId = storage.mintId();
const runningTask: StoredTask = {
...task,
state: { status: "running", checkpoint: { phase: "effect" } },
};
const doneInput: Input = { ...input, status: "done", answer: transientEntryId };
await expect(
storage.commit(
[
{ type: "task", value: runningTask },
{ type: "input", value: doneInput },
{ type: "entry", value: entry(transientEntryId, rootId, "assistant") },
{ type: "conversation", value: { id: rootId } },
],
context,
),
).rejects.toThrow(`ID ${rootId} already belongs to conversation`);
expect(await storage.task(taskId, context)).toEqual(task);
expect(await storage.input(inputId, context)).toEqual(input);
expect(await storage.entries([transientEntryId], context)).toEqual(new Map());
expect(await storage.entryCommit(transientEntryId, context)).toBeUndefined();
expect(
await storage.commit([{ type: "entry", value: entry(storage.mintId(), rootId, "after-rollback") }], context),
).toBe(initialSeq + 1);
});
it("detaches retained writes and every returned record", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const entryId = storage.mintId();
const taskId = storage.mintId();
const inputId = storage.mintId();
const entryData = { nested: [1, 2] };
const checkpoint = { phase: "ready", nested: { count: 1 } };
const detail = { codes: ["initial"] };
const storedEntry = entry(entryId, rootId, "note", { data: entryData });
const storedTask: StoredTask = {
...pendingTask(taskId, rootId),
state: { status: "pending", checkpoint },
};
const storedInput: Input = {
id: inputId,
conversationId: rootId,
status: "unanswered",
reason: "failed",
detail,
};
await storage.commit(
[
{ type: "entry", value: storedEntry },
{ type: "task", value: storedTask },
{ type: "input", value: storedInput },
],
context,
);
entryData.nested.push(3);
checkpoint.nested.count = 2;
detail.codes.push("mutated");
expect((await storage.entries([entryId], context)).get(entryId)?.data).toEqual({ nested: [1, 2] });
expect((await storage.task(taskId, context))?.state).toEqual({
status: "pending",
checkpoint: { phase: "ready", nested: { count: 1 } },
});
expect((await storage.input(inputId, context))?.detail).toEqual({ codes: ["initial"] });
const readEntry = (await storage.entries([entryId], context)).get(entryId)!;
(readEntry.data as { nested: number[] }).nested.push(9);
const readTask = (await storage.task(taskId, context))!;
if (readTask.state.status !== "terminal") {
(readTask.state.checkpoint as { phase: string; nested: { count: number } }).nested.count = 9;
}
const readInput = (await storage.input(inputId, context))!;
(readInput.detail as { codes: string[] }).codes.push("read mutation");
expect((await storage.entries([entryId], context)).get(entryId)?.data).toEqual({ nested: [1, 2] });
expect((await storage.task(taskId, context))?.state).toEqual({
status: "pending",
checkpoint: { phase: "ready", nested: { count: 1 } },
});
expect((await storage.input(inputId, context))?.detail).toEqual({ codes: ["initial"] });
});
it("paginates conversations by opaque cursor in ascending ID order", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const secondId = storage.mintId();
const thirdId = storage.mintId();
await storage.commit(
[
{ type: "conversation", value: { id: secondId } },
{ type: "conversation", value: { id: thirdId } },
],
context,
);
const first = await storage.scanConversations(undefined, 2, context);
expect(first.items.map(({ id }) => id)).toEqual([rootId, secondId]);
expect(first.next).toEqual({ after: secondId });
const second = await storage.scanConversations(first.next, 2, context);
expect(second.items.map(({ id }) => id)).toEqual([thirdId]);
expect(second.next).toBeUndefined();
});
it("scans deep fork history newest-first through every ancestor cap", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const rootFirst = storage.mintId();
const rootForkPoint = storage.mintId();
const rootExcludedSameCommit = storage.mintId();
const rootEntriesSeq = await storage.commit(
[
{ type: "entry", value: entry(rootFirst, rootId) },
{
type: "entry",
value: entry(rootForkPoint, rootId, "marker", { head: rootFirst }),
},
{ type: "entry", value: entry(rootExcludedSameCommit, rootId) },
],
context,
);
const childId = storage.mintId();
await storage.commit(
[
{
type: "conversation",
value: { id: childId, parent: { conversationId: rootId, at: rootForkPoint } },
},
],
context,
);
const childForkPoint = storage.mintId();
const childExcluded = storage.mintId();
await storage.commit(
[
{ type: "entry", value: entry(childForkPoint, childId, "note") },
{ type: "entry", value: entry(childExcluded, childId) },
],
context,
);
const rootExcludedLater = storage.mintId();
await storage.commit([{ type: "entry", value: entry(rootExcludedLater, rootId) }], context);
const grandchildId = storage.mintId();
await storage.commit(
[
{
type: "conversation",
value: { id: grandchildId, parent: { conversationId: childId, at: childForkPoint } },
},
],
context,
);
const grandchildHead = storage.mintId();
const grandchildTail = storage.mintId();
const grandchildEntriesSeq = await storage.commit(
[
{
type: "entry",
value: entry(grandchildHead, grandchildId, "marker", { head: grandchildHead }),
},
{ type: "entry", value: entry(grandchildTail, grandchildId) },
],
context,
);
const childExcludedLater = storage.mintId();
await storage.commit([{ type: "entry", value: entry(childExcludedLater, childId) }], context);
const first = await storage.scanEntries({ conversationId: grandchildId }, undefined, 2, context);
expect(first.items.map(({ id }) => id)).toEqual([grandchildTail, grandchildHead]);
const second = await storage.scanEntries({ conversationId: grandchildId }, first.next, 2, context);
expect(second.items.map(({ id }) => id)).toEqual([childForkPoint, rootForkPoint]);
const third = await storage.scanEntries({ conversationId: grandchildId }, second.next, 2, context);
expect(third.items.map(({ id }) => id)).toEqual([rootFirst]);
expect(third.next).toBeUndefined();
expect(
(
await storage.scanEntries({ conversationId: grandchildId, before: grandchildHead }, undefined, 10, context)
).items.map(({ id }) => id),
).toEqual([childForkPoint, rootForkPoint, rootFirst]);
expect(
(
await storage.scanEntries({ conversationId: grandchildId, kind: "marker" }, undefined, 10, context)
).items.map(({ id }) => id),
).toEqual([grandchildHead, rootForkPoint]);
expect(
(
await storage.scanEntries({ conversationId: grandchildId, withHead: true }, undefined, 10, context)
).items.map(({ id }) => id),
).toEqual([grandchildHead, rootForkPoint]);
expect(await storage.entryCommit(rootFirst, context)).toBe(rootEntriesSeq);
expect(await storage.entryCommit(rootForkPoint, context)).toBe(rootEntriesSeq);
expect(await storage.entryCommit(grandchildHead, context)).toBe(grandchildEntriesSeq);
expect(await storage.entryCommit(grandchildTail, context)).toBe(grandchildEntriesSeq);
expect(await storage.entryCommit(999_999, context)).toBeUndefined();
await expect(storage.scanEntries({ conversationId: 999_999 }, undefined, 10, context)).rejects.toThrow(
"Unknown conversation",
);
});
it("replaces complete task records and pages filtered task scans", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const firstId = storage.mintId();
const secondId = storage.mintId();
const thirdId = storage.mintId();
const first = { ...pendingTask(firstId, rootId), memos: { winner: "first" } } satisfies StoredTask;
const second = { ...pendingTask(secondId, rootId), background: true } satisfies StoredTask;
const third = { ...pendingTask(thirdId, rootId), abortRequested: true } satisfies StoredTask;
await storage.commit(
[
{ type: "task", value: first },
{ type: "task", value: second },
{ type: "task", value: third },
],
context,
);
const running: StoredTask = {
...first,
state: { status: "running", checkpoint: { phase: "effect", attempt: 1 } },
abortRequested: true,
};
await storage.commit([{ type: "task", value: running }], context);
expect(await storage.task(firstId, context)).toEqual(running);
const terminal: StoredTask = {
id: firstId,
conversationId: rootId,
kind: first.kind,
version: first.version,
input: first.input,
state: { status: "terminal", outcome: { status: "completed", result: { entryId: 99 } } },
after: [],
background: false,
abortRequested: true,
};
await storage.commit([{ type: "task", value: terminal }], context);
expect(await storage.task(firstId, context)).toEqual(terminal);
const pendingPage = await storage.scanTasks({ status: "pending" }, undefined, 1, context);
expect(pendingPage.items.map(({ id }) => id)).toEqual([secondId]);
expect(pendingPage.next).toBeDefined();
expect(
(await storage.scanTasks({ status: "pending" }, pendingPage.next, 1, context)).items.map(({ id }) => id),
).toEqual([thirdId]);
expect(
(await storage.scanTasks({ status: "terminal", abortRequested: true }, undefined, 10, context)).items,
).toEqual([terminal]);
expect((await storage.scanTasks({ background: true }, undefined, 10, context)).items.map(({ id }) => id)).toEqual(
[secondId],
);
});
it("indexes request IDs per conversation and replaces complete input records", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const secondConversationId = storage.mintId();
await storage.commit([{ type: "conversation", value: { id: secondConversationId } }], context);
const firstId = storage.mintId();
const secondId = storage.mintId();
const otherConversationId = storage.mintId();
const first: Input = {
id: firstId,
conversationId: rootId,
requestId: "same",
status: "queued",
};
const second: Input = {
id: secondId,
conversationId: rootId,
requestId: "other",
status: "queued",
};
const otherConversation: Input = {
id: otherConversationId,
conversationId: secondConversationId,
requestId: "same",
status: "queued",
};
await storage.commit(
[
{ type: "input", value: first },
{ type: "input", value: second },
{ type: "input", value: otherConversation },
],
context,
);
expect(await storage.inputByRequest(rootId, "same", context)).toEqual(first);
expect(await storage.inputByRequest(secondConversationId, "same", context)).toEqual(otherConversation);
const placedSecond: Input = { ...second, status: "placed", entry: storage.mintId() };
await storage.commit([{ type: "input", value: placedSecond }], context);
expect(await storage.input(secondId, context)).toEqual(placedSecond);
expect(await storage.inputByRequest(rootId, "other", context)).toEqual(placedSecond);
});
it("keeps one global record ID namespace and rejects exhausted ID minting", async () => {
const storage = new MemoryStorage();
const rootId = await createRoot(storage);
const explicitEntryId = 100;
await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], context);
expect(storage.mintId()).toBe(101);
await expect(
storage.commit([{ type: "task", value: pendingTask(explicitEntryId, rootId) }], context),
).rejects.toThrow(`ID ${explicitEntryId} already belongs to entry`);
await storage.commit([{ type: "entry", value: entry(Number.MAX_SAFE_INTEGER, rootId, "last-id") }], context);
expect(() => storage.mintId()).toThrow("ID space is exhausted");
expect(() => storage.mintId()).toThrow("ID space is exhausted");
});
it("rejects every operation after close", async () => {
const storage = new MemoryStorage();
await createRoot(storage);
await storage.close(context);
await expect(storage.conversation(ROOT_CONVERSATION_ID, context)).rejects.toThrow("closed");
await expect(storage.commit([] satisfies StorageWrite[], context)).rejects.toThrow("closed");
expect(() => storage.mintId()).toThrow("closed");
});
});

View file

@ -32,6 +32,8 @@ import type {
AssistantMessage,
CacheRetention,
ImageContent,
JsonObject,
JsonValue,
Model,
ProviderEnv,
ProviderResponse,
@ -437,7 +439,7 @@ function appendBedrockFailureDiagnostic(
fallbackRequestId: string | undefined,
): void {
const metadata = (error as SdkErrorMetadata)?.$metadata;
const details: Record<string, unknown> = {};
const details: JsonObject = {};
if (typeof metadata?.httpStatusCode === "number") details.status = metadata.httpStatusCode;
@ -916,7 +918,7 @@ function createRequiredTextBlock(text: string): ContentBlock.TextMember {
return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER };
}
function sanitizeBedrockDocument(value: DocumentType): DocumentType {
function sanitizeBedrockDocument(value: JsonValue): DocumentType {
if (Array.isArray(value)) {
return value.map(sanitizeBedrockDocument);
}

View file

@ -721,7 +721,7 @@ async function consumeChatStream(
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments || {});
block.partialArgs = (block.partialArgs || "") + argsDelta;
block.arguments = parseStreamingJson<Record<string, unknown>>(block.partialArgs);
block.arguments = parseStreamingJson<ToolCall["arguments"]>(block.partialArgs);
stream.push({
type: "toolcall_delta",
contentIndex: toolBlocksByKey.get(key)!,
@ -736,7 +736,7 @@ async function consumeChatStream(
const block = output.content[index];
if (block.type !== "toolCall") continue;
const toolBlock = block as ToolCall & { partialArgs?: string };
toolBlock.arguments = parseStreamingJson<Record<string, unknown>>(toolBlock.partialArgs);
toolBlock.arguments = parseStreamingJson<ToolCall["arguments"]>(toolBlock.partialArgs);
// Finalize in-place and strip the scratch buffer so replay only
// carries parsed arguments.
delete toolBlock.partialArgs;

View file

@ -357,7 +357,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
output,
createAssistantMessageDiagnostic("provider_transport_failure", error, {
configuredTransport: transport,
fallbackTransport: websocketStarted ? undefined : "sse",
...(websocketStarted ? {} : { fallbackTransport: "sse" }),
eventsEmitted: websocketStarted,
phase: websocketStarted ? "after_message_stream_start" : "before_message_stream_start",
requestBytes: new TextEncoder().encode(bodyJson).byteLength,

View file

@ -13,6 +13,8 @@ import type {
AssistantMessage,
AssistantMessageEvent,
CacheRetention,
JsonObject,
JsonValue,
Model,
ProviderEnv,
SimpleStreamOptions,
@ -95,9 +97,9 @@ type PiMessagesErrorBody = {
export class PiMessagesResponseError extends Error {
code?: string;
readonly diagnosticDetails: Record<string, unknown>;
readonly diagnosticDetails: JsonObject;
constructor(message: string, code: string | undefined, diagnosticDetails: Record<string, unknown>) {
constructor(message: string, code: string | undefined, diagnosticDetails: JsonObject) {
super(message);
this.name = "PiMessagesResponseError";
this.code = code;
@ -147,8 +149,8 @@ function createPiMessagesResponseError(
url: url.toString(),
status: response.status,
statusText: response.statusText,
error: errorBody?.error,
body: errorBody ? undefined : truncateDiagnosticString(body),
...(errorBody?.error === undefined ? {} : { error: errorBody.error as JsonValue }),
...(errorBody ? {} : { body: truncateDiagnosticString(body) }),
timestampMs: Date.now(),
});
}

View file

@ -380,7 +380,7 @@ export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, any>;
arguments: JsonObject;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
/** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */
namespace?: string;
@ -411,7 +411,53 @@ export interface Usage {
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject;
export type JsonObject = { [key: string]: JsonValue };
type IsAny<T> = 0 extends 1 & T ? true : false;
type IsExactlyJsonValue<T> = [T] extends [JsonValue] ? ([JsonValue] extends [T] ? true : false) : false;
type IsJsonProperty<T> = IsAny<T> extends true
? false
: unknown extends T
? false
: [Exclude<T, undefined>] extends [never]
? true
: IsJsonCompatible<Exclude<T, undefined>>;
type InvalidJsonKeys<T extends object> = {
[TKey in keyof T]-?: TKey extends string | number ? (IsJsonProperty<T[TKey]> extends true ? never : TKey) : TKey;
}[keyof T];
type IsJsonCompatible<T> = IsAny<T> extends true
? false
: unknown extends T
? false
: IsExactlyJsonValue<T> extends true
? true
: T extends null | boolean | number | string
? true
: T extends undefined
? false
: T extends readonly (infer TItem)[]
? IsJsonCompatible<TItem>
: T extends (...args: never[]) => unknown
? false
: T extends object
? [InvalidJsonKeys<T>] extends [never]
? true
: false
: false;
/** The JSON representation of a typed in-memory value. Optional object properties remain optional. */
export type JsonRepresentation<T> = IsAny<T> extends true
? JsonValue
: unknown extends T
? JsonValue
: [T] extends [JsonValue]
? T
: T extends readonly unknown[]
? { [TKey in keyof T]: JsonRepresentation<Exclude<T[TKey], undefined>> }
: T extends object
? { [TKey in keyof T]: JsonRepresentation<Exclude<T[TKey], undefined>> }
: never;
export interface DeferredHandle {
provider: string;
@ -490,17 +536,19 @@ export interface AssistantMessage {
timestamp: number; // Unix timestamp in milliseconds
}
export interface ToolResultMessage<TDetails = any> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[]; // Supports text and images
details?: TDetails;
/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */
usage?: Usage;
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
}
export type ToolResultMessage<TDetails = JsonValue> = IsJsonCompatible<TDetails> extends true
? {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[]; // Supports text and images
details?: JsonRepresentation<TDetails>;
/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */
usage?: Usage;
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
}
: never;
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;

View file

@ -1,3 +1,5 @@
import type { JsonObject } from "../types.ts";
export interface DiagnosticErrorInfo {
name?: string;
message: string;
@ -9,7 +11,7 @@ export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
details?: JsonObject;
}
export function formatThrownValue(value: unknown): string {
@ -32,7 +34,7 @@ export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo {
export function createAssistantMessageDiagnostic(
type: string,
error: unknown,
details?: Record<string, unknown>,
details?: JsonObject,
): AssistantMessageDiagnostic {
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
}

View file

@ -352,7 +352,7 @@ describe("bedrock convertMessages skips unknown content types", () => {
});
it("removes empty property names only from replayed Bedrock input", async () => {
const toolArguments = {
const toolArguments: { path: string; edits: Array<Record<string, string>> } = {
path: "/workspace/foobar/file.js",
edits: [
{ oldText: "first", newText: "updated first" },

View file

@ -227,7 +227,8 @@ describe("constrained tool sampling", () => {
},
],
});
for (const invalidArguments of [{}, { payload: 42 }]) {
const invalidArgumentsList: ToolCall["arguments"][] = [{}, { payload: 42 }];
for (const invalidArguments of invalidArgumentsList) {
replayedToolCall.arguments = invalidArguments;
expect(() =>
convertResponsesMessages(makeModel(), context, new Set(["openai"]), {

View file

@ -0,0 +1,57 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { Context, JsonValue, Message, ToolResultMessage } from "../src/types.ts";
const base = {
role: "toolResult" as const,
toolCallId: "call-1",
toolName: "read",
content: [],
isError: false,
timestamp: 1,
};
describe("message JSON types", () => {
it("preserves JSON-compatible detail types without requiring index signatures", () => {
interface Details {
path: string;
items: readonly string[];
summary?: {
lines?: number;
};
}
const objectMessage: ToolResultMessage<Details> = {
...base,
details: { path: "a.ts", items: ["first"], summary: { lines: 3 } },
};
const arrayMessage: ToolResultMessage<string[]> = { ...base, details: ["a", "b"] };
const readonlyArrayMessage: ToolResultMessage<readonly [string, { readonly count?: number }]> = {
...base,
details: ["a", { count: 1 }],
};
const primitiveMessage: ToolResultMessage<string> = { ...base, details: "diagnostic" };
const nullMessage: ToolResultMessage<null> = { ...base, details: null };
const transcriptMessage: Message = objectMessage;
const readonlyTranscriptMessage: Message = readonlyArrayMessage;
const context: Context = { messages: [objectMessage, readonlyArrayMessage] };
expect(objectMessage.details?.summary?.lines).toBe(3);
expect(arrayMessage.details).toEqual(["a", "b"]);
expect(readonlyArrayMessage.details).toEqual(["a", { count: 1 }]);
expect(primitiveMessage.details).toBe("diagnostic");
expect(nullMessage.details).toBeNull();
expect(transcriptMessage).toBe(objectMessage);
expect(readonlyTranscriptMessage).toBe(readonlyArrayMessage);
expect(context.messages).toEqual([objectMessage, readonlyArrayMessage]);
expectTypeOf<ToolResultMessage["details"]>().toEqualTypeOf<JsonValue | undefined>();
});
it("rejects non-JSON detail types", () => {
type OptionalAny = { value?: any };
expectTypeOf<ToolResultMessage<OptionalAny>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<undefined[]>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<{ callback: () => void }>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<{ value: unknown }>>().toEqualTypeOf<never>();
expectTypeOf<ToolResultMessage<{ value: Date }>>().toEqualTypeOf<never>();
});
});

View file

@ -305,6 +305,7 @@ async function multiTurn<TApi extends Api>(model: Model<TApi>, options?: StreamO
expect(block.arguments).toBeTruthy();
const { a, b, operation } = block.arguments;
if (typeof a !== "number" || typeof b !== "number") throw new Error("Invalid math arguments");
let result: number;
switch (operation) {
case "add":

View file

@ -1,7 +1,7 @@
import { Type } from "typebox";
import { Compile } from "typebox/compile";
import { describe, expect, it } from "vitest";
import type { Tool, ToolCall } from "../src/types.ts";
import type { JsonValue, Tool, ToolCall } from "../src/types.ts";
import { validateToolArguments } from "../src/utils/validation.ts";
function createToolCallWithPlainSchema(
@ -27,7 +27,7 @@ function createToolCallWithPlainSchema(
type: "toolCall",
id: "tool-1",
name: "echo",
arguments: { value },
arguments: { value: value as JsonValue },
};
return { tool, toolCall };

View file

@ -3,6 +3,7 @@ import {
fauxAssistantMessage,
fauxToolCall,
getCurrentSystemPrompt,
type JsonObject,
type Model,
type Usage,
} from "@earendil-works/pi-ai";
@ -345,7 +346,12 @@ describe("AgentSession model and extension characterization", () => {
expect(getAssistantTexts(harness)).toContain("patched result");
const toolResult = harness.session.messages.find(
(message) => message.role === "toolResult" && message.details?.patched === true,
(message) =>
message.role === "toolResult" &&
typeof message.details === "object" &&
message.details !== null &&
!Array.isArray(message.details) &&
(message.details as JsonObject).patched === true,
);
expect(observedToolUsage).toEqual(toolUsage);
expect(toolResult).toBeDefined();

View file

@ -17,6 +17,7 @@ import type {
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
JsonObject,
Model,
SimpleStreamOptions,
StopReason,
@ -68,7 +69,7 @@ export interface FauxResponse {
/** Text content blocks. String shorthand becomes a single text block. */
text?: string;
/** Tool calls to include in the response. */
toolCalls?: Array<{ id?: string; name: string; args: Record<string, unknown> }>;
toolCalls?: Array<{ id?: string; name: string; args: JsonObject }>;
/** Thinking content. */
thinking?: string;
/** Stop reason. Defaults to "stop", or "toolUse" if toolCalls are present, or "error" if error is set. */