Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
49 changes: 49 additions & 0 deletions .changeset/reasoning-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@electric-ax/agents-server-ui': minor
'@electric-ax/agents-runtime': minor
'@electric-ax/agents': patch
'@electric-ax/agents-desktop': patch
---

Stream model reasoning / extended-thinking content into the UI. While
the model is "thinking" (Anthropic extended thinking, DeepSeek-R1
reasoning, Moonshot K2, OpenAI Responses summaries) the agent response
now shows the live reasoning text faded above the answer, with the
existing `Thinking` shimmer heading and an elapsed-time ticker. Once
the reasoning settles it collapses to `▸ Thought for 12s` — click to
expand. Multiple reasoning rows per run are rendered independently in
order, so tool-using turns show each step's reasoning separately.

Implementation:

- **Schema** — `reasoning` row gains `run_id`, `encrypted` (Anthropic
redacted-thinking opaque payload, must round-trip back to the model
verbatim), and `summary_title` (extracted at write time for
providers that emit a bolded heading). New `reasoningDeltas`
collection mirrors `textDeltas` for streamed content.
- **Bridge** — `OutboundBridge` gains `onReasoningStart` /
`onReasoningDelta` / `onReasoningEnd`, parallel to the text path.
- **Adapter** — `pi-adapter.ts` routes pi-ai's `thinking_start` /
`thinking_delta` / `thinking_end` events to the bridge, parses the
`**Title**\n\n<body>` heading (OpenAI Responses only) once at
`thinking_end` so the UI doesn't re-parse on every render.
- **Timeline** — `EntityTimelineRunRow` gains a live
`reasoning: Collection<EntityTimelineReasoningItem>` with content
built from a delta-join, mirroring `EntityTimelineTextItem`.
- **UI** — New `<ReasoningSection>` component renders above the
answer in `AgentResponseLive`. Live shows faded markdown via
`Streamdown` with `ThinkingIndicator` heading + summary title +
elapsed-time ticker. Settled collapses to `Thought for Ns` with
click-to-expand. Redacted Anthropic blocks render a single muted
line — content is opaque, but the encrypted payload is still
persisted server-side so the model gets it back next turn.

Providers without reasoning emit nothing → no reasoning section
rendered. Historical responses recorded before this PR have no
closure cue, same as today.

Anthropic extended thinking is now always-on for reasoning-capable
models: `reasoningEffort: auto` maps to the minimal budget
(1024 tokens), matching the OpenAI branch where `auto` already
defaulted to `minimal`. Explicit `low`/`medium`/`high` scale the
budget as before.
41 changes: 41 additions & 0 deletions packages/agents-runtime/src/entity-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,24 @@ type ToolCallValue = {
}
type ReasoningValue = {
key?: string
run_id?: string
status: `streaming` | `completed`
// Anthropic emits "redacted thinking" content blocks the client can't
// display but MUST round-trip back to the model on the next turn or
// the conversation errors. Persist verbatim, render nothing.
encrypted?: string
// OpenAI's Responses API surfaces reasoning with a bolded title line
// (`**Inspecting PR workflow**\n\n<body>`). We split it out at write
// time so the UI can drive a separate heading without re-parsing on
// every render. Empty / absent for providers that don't emit titles
// (Anthropic, DeepSeek-R1, Moonshot K2).
summary_title?: string
}
type ReasoningDeltaValue = {
key?: string
reasoning_id: string
run_id: string
delta: string
}
type ErrorEventValue = {
key?: string
Expand Down Expand Up @@ -530,7 +547,20 @@ function createReasoningSchema(): Schema<ReasoningValue> {
return z.object({
key: z.string().optional(),
...timelineOrderField,
run_id: z.string().optional(),
status: z.enum([`streaming`, `completed`]),
encrypted: z.string().optional(),
summary_title: z.string().optional(),
})
}

function createReasoningDeltaSchema(): Schema<ReasoningDeltaValue> {
return z.object({
key: z.string().optional(),
...timelineOrderField,
reasoning_id: z.string(),
run_id: z.string(),
delta: z.string(),
})
}

Expand Down Expand Up @@ -861,6 +891,7 @@ export type Text = SequencedPersistedRow<TextValue>
export type TextDelta = SequencedPersistedRow<TextDeltaValue>
export type ToolCall = SequencedPersistedRow<ToolCallValue>
export type Reasoning = SequencedPersistedRow<ReasoningValue>
export type ReasoningDelta = SequencedPersistedRow<ReasoningDeltaValue>
export type ErrorEvent = SequencedPersistedRow<ErrorEventValue>
export type MessageReceived = SequencedPersistedRow<MessageReceivedValue>
export type WakeEntry = SequencedPersistedRow<WakeEntryValue>
Expand Down Expand Up @@ -951,6 +982,7 @@ export const ENTITY_COLLECTIONS = {
textDeltas: `textDeltas`,
toolCalls: `toolCalls`,
reasoning: `reasoning`,
reasoningDeltas: `reasoningDeltas`,
errors: `errors`,
inbox: `inbox`,
wakes: `wakes`,
Expand All @@ -975,6 +1007,8 @@ export const BUILT_IN_EVENT_SCHEMAS = {
tool_call: createToolCallSchema() as unknown as BuiltInEntitySchema<ToolCall>,
reasoning:
createReasoningSchema() as unknown as BuiltInEntitySchema<Reasoning>,
reasoning_delta:
createReasoningDeltaSchema() as unknown as BuiltInEntitySchema<ReasoningDelta>,
error: createErrorEventSchema() as unknown as BuiltInEntitySchema<ErrorEvent>,
inbox:
createMessageReceivedSchema() as unknown as BuiltInEntitySchema<MessageReceived>,
Expand Down Expand Up @@ -1010,6 +1044,7 @@ type EntityCollectionsDefinition = {
textDeltas: CollectionDefinition<TextDelta>
toolCalls: CollectionDefinition<ToolCall>
reasoning: CollectionDefinition<Reasoning>
reasoningDeltas: CollectionDefinition<ReasoningDelta>
errors: CollectionDefinition<ErrorEvent>
inbox: CollectionDefinition<MessageReceived>
wakes: CollectionDefinition<WakeEntry>
Expand Down Expand Up @@ -1062,6 +1097,12 @@ export const builtInCollections: EntityCollectionsDefinition = {
type: `reasoning`,
primaryKey: `key`,
},
reasoningDeltas: {
schema:
BUILT_IN_EVENT_SCHEMAS.reasoning_delta as StandardSchemaV1<ReasoningDelta>,
type: `reasoning_delta`,
primaryKey: `key`,
},
errors: {
schema: BUILT_IN_EVENT_SCHEMAS.error as StandardSchemaV1<ErrorEvent>,
type: `error`,
Expand Down
97 changes: 92 additions & 5 deletions packages/agents-runtime/src/entity-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ export type EntityTimelineRunItem =
toolCall: EntityTimelineToolCallItem
}

export interface EntityTimelineReasoningItem {
key: string
run_id?: string
order: TimelineOrder
status: `streaming` | `completed`
// The concatenated `reasoning_delta` content lives under
// `body.content` rather than top-level — the wrapper is what
// forces TanStack DB to materialize the include before the row
// reaches `useLiveQuery`. See the timeline-query comment.
body?: { content: string }
// Optional bolded title parsed at write time — only OpenAI Responses
// emits these; null for Anthropic / DeepSeek / Moonshot.
summary_title?: string
// Anthropic redacted-thinking opaque payload. Persist verbatim so we
// can echo it back on the next turn; the UI shows a placeholder.
encrypted?: string
}

export interface EntityTimelineStepItem {
key: string
run_id?: string
Expand All @@ -267,6 +285,7 @@ export interface EntityTimelineRunRow {
status: `started` | `completed` | `failed`
finish_reason?: string
items: Collection<EntityTimelineRunItem>
reasoning: Collection<EntityTimelineReasoningItem>
steps: Collection<EntityTimelineStepItem>
errors: Collection<EntityTimelineErrorItem>
// Per-run token totals summed across all `steps` of the run.
Expand Down Expand Up @@ -1385,6 +1404,13 @@ function buildEntityTimelineQuery(
run_id: error.run_id,
}))

// Union texts + tool calls into a single ordered stream. The
// text-delta join lives at this level (vs. inside the consumer's
// `items.select`) so the correlation key is `text.key` — a field
// on the raw text row — rather than a projected scalar. The only
// delta-join alias constraint is that it must NOT collide with
// the `chunk` alias used in the reasoning content sub-query
// below; that's why this one is `textChunk`.
const runItemsSource = q
.unionAll({
text: db.collections.texts,
Expand All @@ -1402,11 +1428,13 @@ function buildEntityTimelineQuery(
textContent: concat(
toArray(
q
.from({ chunk: db.collections.textDeltas })
.where(({ chunk }) => eq(chunk.text_id, text.key))
.orderBy(({ chunk }) => coalesce(chunk._timeline_order, `~`))
.orderBy(({ chunk }) => chunk.key)
.select(({ chunk }) => chunk.delta)
.from({ textChunk: db.collections.textDeltas })
.where(({ textChunk }) => eq(textChunk.text_id, text.key))
.orderBy(({ textChunk }) =>
coalesce(textChunk._timeline_order, `~`)
)
.orderBy(({ textChunk }) => textChunk.key)
.select(({ textChunk }) => textChunk.delta)
)
),
toolCall: caseWhen(toolCall.key, {
Expand All @@ -1422,6 +1450,43 @@ function buildEntityTimelineQuery(
}),
}))

// Mirror `runItemsSource`'s shape for reasoning rows: the
// `concat(toArray(...))` include is *defined* on this top-level
// source, then the `reasoning:` consumer inside `runSource.select`
// below dereferences it into `content: r.reasoningContent`. The
// two-layer source/consumer split is load-bearing: `useLiveQuery`
// reads of a sub-collection that has an include co-defined in the
// same select return the row with `content: null` + a deferred
// `Symbol(includesRouting)` marker. Naming the include field in a
// downstream `.select` is what forces materialization — exactly
// how `items.text.content` pulls `item.textContent` out of
// `runItemsSource`. Alias is `reasoningChunk` to avoid colliding
// with `textChunk` used above.
const runReasoningSource = q
.from({ reasoning: db.collections.reasoning })
.select(({ reasoning }) => ({
key: reasoning.key,
run_id: reasoning.run_id,
order: coalesce(reasoning._timeline_order, `~`),
status: reasoning.status,
summary_title: reasoning.summary_title,
encrypted: reasoning.encrypted,
reasoningContent: concat(
toArray(
q
.from({ reasoningChunk: db.collections.reasoningDeltas })
.where(({ reasoningChunk }) =>
eq(reasoningChunk.reasoning_id, reasoning.key)
)
.orderBy(({ reasoningChunk }) =>
coalesce(reasoningChunk._timeline_order, `~`)
)
.orderBy(({ reasoningChunk }) => reasoningChunk.key)
.select(({ reasoningChunk }) => reasoningChunk.delta)
)
),
}))

const runTokensSource = q
.from({ step: db.collections.steps })
.groupBy(({ step }) => step.run_id)
Expand Down Expand Up @@ -1484,6 +1549,28 @@ function buildEntityTimelineQuery(
}),
toolCall: item.toolCall,
})),
reasoning: q
.from({ r: runReasoningSource })
.where(({ r }) => eq(r.run_id, run.key))
.orderBy(({ r }) => r.order)
.orderBy(({ r }) => r.key)
.select(({ r }) => ({
key: r.key,
run_id: r.run_id,
order: r.order,
status: r.status,
// Wrap the include reference inside a `caseWhen` object body
// — the same construct items uses to materialize
// `item.textContent` into `text.content`. Bare top-level
// references leave the include deferred until UI reads it
// through `useLiveQuery`, which never gets through. UI reads
// `entry.body?.content` instead of `entry.content`.
body: caseWhen(r.key, {
content: r.reasoningContent,
}),
summary_title: r.summary_title,
encrypted: r.encrypted,
})),
steps: q
.from({ step: db.collections.steps })
.where(({ step }) => eq(step.run_id, run.key))
Expand Down
Loading
Loading