mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): reference-based model requests + wire-form composer
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a58493b545
commit
3822bf53da
18 changed files with 524 additions and 227 deletions
|
|
@ -82,7 +82,7 @@ describe('useSessionChat', () => {
|
|||
// A new turn streams in over the feed.
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0, [user('q2')]) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
|
||||
emit({
|
||||
kind: 'turn-event',
|
||||
sessionId: S1,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ describe('SessionChatStore', () => {
|
|||
await store.setSession(S1)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')])))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
expect(store.getSnapshot().chatState?.isThinking).toBe(true)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
|
|
@ -161,7 +161,7 @@ describe('SessionChatStore', () => {
|
|||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')])))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'he' }))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'y' }))
|
||||
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('hey')
|
||||
|
|
@ -199,7 +199,7 @@ describe('SessionChatStore', () => {
|
|||
// The feed attached mid-turn: we never saw turn_created for turn-9.
|
||||
client.turns.set('turn-9', [
|
||||
created('turn-9', S1, user('missed')),
|
||||
requested('turn-9', 0, [user('missed')]),
|
||||
requested('turn-9', 0),
|
||||
])
|
||||
emit(turnEvent(S1, 'turn-9', completed('turn-9', 0, assistantText('caught up'))))
|
||||
await Promise.resolve()
|
||||
|
|
@ -273,7 +273,7 @@ describe('SessionChatStore', () => {
|
|||
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')])))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
|
||||
expect(store.getSnapshot().chatState?.isProcessing).toBe(false)
|
||||
|
|
|
|||
|
|
@ -46,16 +46,24 @@ export function created(
|
|||
}
|
||||
}
|
||||
|
||||
export function requested(turnId: string, index: number, messages: unknown[]): TEvent {
|
||||
type RequestRef =
|
||||
| { kind: 'context' }
|
||||
| { kind: 'input' }
|
||||
| { kind: 'assistant'; modelCallIndex: number }
|
||||
| { kind: 'toolResult'; toolCallId: string }
|
||||
|
||||
export function requested(
|
||||
turnId: string,
|
||||
index: number,
|
||||
refs: RequestRef[] = [{ kind: 'input' }],
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'model_call_requested',
|
||||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
request: {
|
||||
systemPrompt: 'SYS',
|
||||
messages: messages as never,
|
||||
tools: [],
|
||||
messages: refs,
|
||||
parameters: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -127,7 +135,7 @@ export function completedTurnLog(
|
|||
): TEvent[] {
|
||||
return [
|
||||
created(turnId, sessionId, user(question)),
|
||||
requested(turnId, 0, [user(question)]),
|
||||
requested(turnId, 0),
|
||||
completed(turnId, 0, assistantText(answer)),
|
||||
turnCompleted(turnId, answer),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -87,14 +87,13 @@ describe('buildTurnConversation', () => {
|
|||
const call = assistantCalls(toolCallPart('tc1', 'echo', { x: 1 }))
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, user('run it')),
|
||||
requested(T1, 0, [user('run it')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, call),
|
||||
invocation(T1, 'tc1', 'echo'),
|
||||
toolResult(T1, 'tc1', 'echo', { echoed: true }),
|
||||
requested(T1, 1, [
|
||||
user('run it'),
|
||||
call,
|
||||
{ role: 'tool', content: '{"echoed":true}', toolCallId: 'tc1', toolName: 'echo' },
|
||||
{ kind: 'assistant', modelCallIndex: 0 },
|
||||
{ kind: 'toolResult', toolCallId: 'tc1' },
|
||||
]),
|
||||
completed(T1, 1, assistantText('all done')),
|
||||
turnCompleted(T1, 'all done'),
|
||||
|
|
@ -113,7 +112,7 @@ describe('buildTurnConversation', () => {
|
|||
it('marks permission-pending tools pending and running tools running', () => {
|
||||
const state = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'), toolCallPart('r1', 'echo'))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
|
|
@ -139,7 +138,7 @@ describe('buildTurnConversation', () => {
|
|||
}
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, input as never),
|
||||
requested(T1, 0, [input]),
|
||||
requested(T1, 0),
|
||||
{ type: 'model_call_failed', turnId: T1, ts: TS, modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed', turnId: T1, ts: TS, error: 'boom', usage: {} },
|
||||
])
|
||||
|
|
@ -154,7 +153,7 @@ describe('buildSessionChatState', () => {
|
|||
const prior = reduceTurn(completedTurnLog('turn-1', S1, 'first?', 'first answer'))
|
||||
const latest = reduceTurn([
|
||||
created('turn-2', S1, user('second?')),
|
||||
requested('turn-2', 0, [user('second?')]),
|
||||
requested('turn-2', 0),
|
||||
])
|
||||
const state = buildSessionChatState([prior, latest], emptyOverlay())
|
||||
expect(
|
||||
|
|
@ -174,7 +173,7 @@ describe('buildSessionChatState', () => {
|
|||
it('exposes pending permissions as request events; waiting is not thinking', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand', { command: 'rm -rf /' }))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
|
|
@ -209,7 +208,7 @@ describe('buildSessionChatState', () => {
|
|||
it('maps human decisions to responses and classifier decisions to auto-decisions', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('h1', 'echo'), toolCallPart('c1', 'echo'))),
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'h1', toolName: 'echo', request: { kind: 'command', commandNames: ['x'] } },
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', request: { kind: 'command', commandNames: ['y'] } },
|
||||
|
|
@ -230,7 +229,7 @@ describe('buildSessionChatState', () => {
|
|||
it('exposes pending ask-human calls with question and options', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('ah1', 'ask-human', { question: 'Deploy?', options: ['Yes', 'No'] }))),
|
||||
{
|
||||
type: 'tool_invocation_requested',
|
||||
|
|
@ -254,7 +253,7 @@ describe('buildSessionChatState', () => {
|
|||
it('stitches live tool output onto the matching tool item', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0, [user('hello')]),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('tc1', 'executeCommand'))),
|
||||
invocation(T1, 'tc1', 'executeCommand'),
|
||||
])
|
||||
|
|
@ -265,7 +264,7 @@ describe('buildSessionChatState', () => {
|
|||
})
|
||||
|
||||
it('surfaces streaming text as currentAssistantMessage', () => {
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0, [user('hello')])])
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0)])
|
||||
const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' })
|
||||
expect(state.currentAssistantMessage).toBe('typing…')
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue