Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5f4113d
feat(agents-server): add project store with env-paths
KyleAMathews May 4, 2026
0fef98d
feat(agents-server): add project CRUD API routes
KyleAMathews May 4, 2026
73c70fb
feat(agents): add optional workingDirectory spawn arg to horton
KyleAMathews May 4, 2026
75eff41
feat(agents-server-ui): rewrite useProjects to fetch from server API
KyleAMathews May 4, 2026
3cfd09c
feat(agents-server-ui): redesign new session page with hero project p…
KyleAMathews May 4, 2026
cf714db
feat(agents-server-ui): rotating hero verbs and bigger heading
KyleAMathews May 4, 2026
4d19efe
feat(agents-server-ui): switch body font from Inter to Figtree
KyleAMathews May 4, 2026
2ac4711
fix: review cleanup — off-by-one, stale project ID, cwd validation
KyleAMathews May 4, 2026
ed4b4ba
chore: add changeset for projects feature
KyleAMathews May 4, 2026
3dfb9e4
feat(agents-server-ui): add 404 page for missing entities and unknown…
KyleAMathews May 4, 2026
2980f18
style(agents-server-ui): warmer light theme and surface polish
KyleAMathews May 4, 2026
d2e388a
style(agents-server-ui): tool trace polish, focus fix, wider column
KyleAMathews May 4, 2026
8861a89
agents: load AGENTS.md into Horton context
May 4, 2026
30dc146
store streams data
KyleAMathews May 4, 2026
5433763
agents: wrap AGENTS.md context in XML
KyleAMathews May 4, 2026
d07b19b
fix(agents-server): add missing nanoid dependency
KyleAMathews May 4, 2026
113bba4
feat(agents-server-ui): add "No project" option to project picker
KyleAMathews May 5, 2026
1f1efac
feat(agents-server-ui): dark mode polish and final tweaks
KyleAMathews May 5, 2026
f96a836
Fix tool call event matching
KyleAMathews May 5, 2026
b0b0d3e
fix(agents-server-ui): fix dark mode code block syntax colors and tra…
KyleAMathews May 5, 2026
3c5dd25
chore(agents): remove --watch from start scripts
KyleAMathews May 5, 2026
273a7b9
feat(agents-server-ui): preload entity stream on sidebar hover via Li…
KyleAMathews May 5, 2026
2a77765
chore(agents-server-ui): remove onSelect prop from sidebar — navigati…
KyleAMathews May 5, 2026
4938cbc
chore: add changeset for agents-runtime tool call fix
KyleAMathews May 5, 2026
c4cbe11
fix(agents-server-ui): set markdown prose line-height to 1.5
KyleAMathews May 5, 2026
46ea038
fix(agents-runtime): preserve tool_call/tool_result pairing during bu…
KyleAMathews May 5, 2026
a078c5f
fix(agents-runtime): drop orphaned tool_call/tool_result after budget…
KyleAMathews May 5, 2026
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
7 changes: 7 additions & 0 deletions .changeset/add-projects-working-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@electric-ax/agents-server': patch
'@electric-ax/agents-server-ui': patch
'@electric-ax/agents': patch
---

Add server-side projects (name + directory path) with REST API, popover-based project picker in the new session page, and working directory support in horton. Sessions are tagged by project for sidebar grouping.
5 changes: 5 additions & 0 deletions .changeset/agents-runtime-tool-call-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-ax/agents-runtime': patch
---

Fix tool call event matching.
31 changes: 31 additions & 0 deletions packages/agents-runtime/src/context-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,43 @@ export async function assembleContext(
const message = volatileMessages[i]!
const nextTokens = approxTokens(message.content)
if (volatileBudgetUsed + nextTokens > remainingBudget) {
if (message.role === `tool_call` || message.role === `tool_result`) {
const stub = `[content truncated — use load_timeline_range({ from: ${message.at}, to: ${message.at} }) to read]`
const stubTokens = approxTokens(stub)
if (volatileBudgetUsed + stubTokens <= remainingBudget) {
volatileBudgetUsed += stubTokens
accepted.push({ ...message, content: stub })
continue
}
}
droppedOffsets.push(message.at)
continue
}
volatileBudgetUsed += nextTokens
accepted.push(message)
}

const acceptedCallIds = new Set<string>()
const acceptedResultIds = new Set<string>()
for (const m of accepted) {
const id = (m as VolatileMessage & { toolCallId?: string }).toolCallId
if (!id) continue
if (m.role === `tool_call`) acceptedCallIds.add(id)
else if (m.role === `tool_result`) acceptedResultIds.add(id)
}
for (let i = accepted.length - 1; i >= 0; i--) {
const m = accepted[i]!
const id = (m as VolatileMessage & { toolCallId?: string }).toolCallId
if (!id) continue
if (
(m.role === `tool_call` && !acceptedResultIds.has(id)) ||
(m.role === `tool_result` && !acceptedCallIds.has(id))
) {
droppedOffsets.push(m.at)
accepted.splice(i, 1)
}
}

accepted.reverse()

if (droppedOffsets.length > 0) {
Expand Down
2 changes: 2 additions & 0 deletions packages/agents-runtime/src/entity-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type TextDeltaValue = {
type ToolCallValue = {
key?: string
run_id?: string
tool_call_id?: string
tool_name: string
status: `started` | `args_complete` | `executing` | `completed` | `failed`
args?: unknown
Expand Down Expand Up @@ -353,6 +354,7 @@ function createToolCallSchema(): Schema<ToolCallValue> {
return z.object({
key: z.string().optional(),
run_id: z.string().optional(),
tool_call_id: z.string().optional(),
tool_name: z.string(),
status: z.enum([
`started`,
Expand Down
44 changes: 29 additions & 15 deletions packages/agents-runtime/src/outbound-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,13 @@ export interface OutboundBridge {
onTextStart: () => void
onTextDelta: (delta: string) => void
onTextEnd: () => void
onToolCallStart: (name: string, args: unknown) => void
onToolCallEnd: (name: string, result: unknown, isError: boolean) => void
onToolCallStart: (toolCallId: string, name: string, args: unknown) => void
onToolCallEnd: (
toolCallId: string,
name: string,
result: unknown,
isError: boolean
) => void
}

export function createOutboundBridge(
Expand Down Expand Up @@ -145,9 +150,10 @@ export function createOutboundBridge(
let currentStepNumber = 0
let currentMsgKey: string | null = null
let currentTextRunKey: string | null = null
let currentTcKey: string | null = null
let currentTcRunKey: string | null = null
let currentTcArgs: unknown = undefined
const toolCallsById = new Map<
string,
{ key: string; runKey: string; args: unknown }
>()
const requireActiveRun = (action: string): string => {
if (!currentRunKey) {
throw new Error(
Expand Down Expand Up @@ -268,16 +274,16 @@ export function createOutboundBridge(
)
},

onToolCallStart(name: string, args: unknown) {
onToolCallStart(toolCallId: string, name: string, args: unknown) {
const runKey = requireActiveRun(`onToolCallStart`)
currentTcKey = `tc-${counters.tc++}`
const key = `tc-${counters.tc++}`
persistSeed()
currentTcRunKey = runKey
currentTcArgs = args
toolCallsById.set(toolCallId, { key, runKey, args })
writeEvent(
entityStateSchema.toolCalls.insert({
key: currentTcKey,
key,
value: {
tool_call_id: toolCallId,
tool_name: name,
status: `started`,
args,
Expand All @@ -287,21 +293,29 @@ export function createOutboundBridge(
)
},

onToolCallEnd(name: string, result: unknown, isError: boolean) {
if (!currentTcKey) return
onToolCallEnd(
toolCallId: string,
name: string,
result: unknown,
isError: boolean
) {
const toolCall = toolCallsById.get(toolCallId)
if (!toolCall) return
writeEvent(
entityStateSchema.toolCalls.update({
key: currentTcKey,
key: toolCall.key,
value: {
tool_call_id: toolCallId,
tool_name: name,
status: isError ? `failed` : `completed`,
args: currentTcArgs,
args: toolCall.args,
result:
typeof result === `string` ? result : JSON.stringify(result),
run_id: currentTcRunKey,
run_id: toolCall.runKey,
} as never,
}) as ChangeEvent
)
toolCallsById.delete(toolCallId)
},
}
}
66 changes: 45 additions & 21 deletions packages/agents-runtime/src/pi-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export function toAgentHistory(
const history: Array<AgentMessage> = []
const toolNamesById = new Map<string, string>()

const lastAssistant = (): AgentMessage | undefined => {
const last = history[history.length - 1]
return last?.role === `assistant` ? last : undefined
}

for (const message of messages) {
switch (message.role) {
case `user`:
Expand All @@ -99,30 +104,44 @@ export function toAgentHistory(
} as AgentMessage)
break

case `assistant`:
history.push({
role: `assistant`,
content: [{ type: `text`, text: message.content }],
timestamp: Date.now(),
} as AgentMessage)
case `assistant`: {
const prev = lastAssistant()
if (prev) {
;(prev.content as Array<unknown>).push({
type: `text`,
text: message.content,
})
} else {
history.push({
role: `assistant`,
content: [{ type: `text`, text: message.content }],
timestamp: Date.now(),
} as AgentMessage)
}
break
}

case `tool_call`:
case `tool_call`: {
toolNamesById.set(message.toolCallId, message.toolName)
history.push({
role: `assistant`,
content: [
{
type: `toolCall`,
id: message.toolCallId,
name: message.toolName,
arguments:
(message.toolArgs as Record<string, unknown> | undefined) ?? {},
},
],
timestamp: Date.now(),
} as AgentMessage)
const block = {
type: `toolCall`,
id: message.toolCallId,
name: message.toolName,
arguments:
(message.toolArgs as Record<string, unknown> | undefined) ?? {},
}
const prev = lastAssistant()
if (prev) {
;(prev.content as Array<unknown>).push(block)
} else {
history.push({
role: `assistant`,
content: [block],
timestamp: Date.now(),
} as AgentMessage)
}
break
}

case `tool_result`:
history.push({
Expand Down Expand Up @@ -297,12 +316,17 @@ export function createPiAgentAdapter(
}

case `tool_execution_start`: {
bridge.onToolCallStart(event.toolName, event.args)
bridge.onToolCallStart(
event.toolCallId,
event.toolName,
event.args
)
break
}

case `tool_execution_end`: {
bridge.onToolCallEnd(
event.toolCallId,
event.toolName,
event.result,
event.isError
Expand Down
9 changes: 7 additions & 2 deletions packages/agents-runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,8 +753,13 @@ export interface OutboundBridgeHandle {
onTextStart: () => void
onTextDelta: (delta: string) => void
onTextEnd: () => void
onToolCallStart: (name: string, args: unknown) => void
onToolCallEnd: (name: string, result: unknown, isError: boolean) => void
onToolCallStart: (toolCallId: string, name: string, args: unknown) => void
onToolCallEnd: (
toolCallId: string,
name: string,
result: unknown,
isError: boolean
) => void
}

export interface AgentHandle {
Expand Down
8 changes: 8 additions & 0 deletions packages/agents-runtime/test/brave-search-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest'
import { braveSearchTool } from '../src/tools'

describe(`braveSearchTool`, () => {
it(`is exposed to agents as web_search`, () => {
expect(braveSearchTool.name).toBe(`web_search`)
})
})
46 changes: 37 additions & 9 deletions packages/agents-runtime/test/outbound-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ describe(`createOutboundBridge`, () => {

it(`rejects tool call outside an active run`, () => {
const bridge = createOutboundBridge([], () => {})
expect(() => bridge.onToolCallStart(`search`, {})).toThrow(/active run/i)
expect(() => bridge.onToolCallStart(`call-search`, `search`, {})).toThrow(
/active run/i
)
})

it(`rejects step start outside an active run`, () => {
Expand All @@ -84,7 +86,7 @@ describe(`createOutboundBridge`, () => {
})

bridge.onRunStart()
bridge.onToolCallStart(`search`, { q: `test` })
bridge.onToolCallStart(`call-search`, `search`, { q: `test` })

expect(writes).toHaveLength(2)
expect(writes[1]!.type).toBe(`tool_call`)
Expand All @@ -103,8 +105,8 @@ describe(`createOutboundBridge`, () => {
})

bridge.onRunStart()
bridge.onToolCallStart(`search`, { q: `test` })
bridge.onToolCallEnd(`search`, `3 results`, false)
bridge.onToolCallStart(`call-search`, `search`, { q: `test` })
bridge.onToolCallEnd(`call-search`, `search`, `3 results`, false)

expect(writes).toHaveLength(3)
expect(writes[2]!.type).toBe(`tool_call`)
Expand All @@ -126,13 +128,37 @@ describe(`createOutboundBridge`, () => {
})

bridge.onRunStart()
bridge.onToolCallStart(`bash`, { cmd: `rm -rf /` })
bridge.onToolCallEnd(`bash`, `Permission denied`, true)
bridge.onToolCallStart(`call-bash`, `bash`, { cmd: `rm -rf /` })
bridge.onToolCallEnd(`call-bash`, `bash`, `Permission denied`, true)

expect((writes[2]!.value as Record<string, unknown>).status).toBe(`failed`)
expect((writes[2]!.value as Record<string, unknown>).run_id).toBe(`run-0`)
})

it(`updates the matching tool call when multiple explicit calls overlap`, () => {
const writes: Array<ChangeEvent> = []
const bridge = createOutboundBridge([], (e) => {
writes.push(e)
})

bridge.onRunStart()
bridge.onToolCallStart(`call-a`, `read`, { path: `a.txt` })
bridge.onToolCallStart(`call-b`, `read`, { path: `b.txt` })
bridge.onToolCallEnd(`call-a`, `read`, `A`, false)
bridge.onToolCallEnd(`call-b`, `read`, `B`, false)

expect(writes[3]!.key).toBe(`tc-0`)
expect((writes[3]!.value as Record<string, unknown>).tool_call_id).toBe(
`call-a`
)
expect((writes[3]!.value as Record<string, unknown>).result).toBe(`A`)
expect(writes[4]!.key).toBe(`tc-1`)
expect((writes[4]!.value as Record<string, unknown>).tool_call_id).toBe(
`call-b`
)
expect((writes[4]!.value as Record<string, unknown>).result).toBe(`B`)
})

it(`reconstructs ID counters from existing stream events`, () => {
const existing: Array<ChangeEvent> = [
ev(`run`, `run-2`, `insert`, { status: `started` }),
Expand All @@ -152,7 +178,7 @@ describe(`createOutboundBridge`, () => {
expect(writes[0]!.key).toBe(`run-3`)
expect(writes[1]!.key).toBe(`msg-4`)

bridge.onToolCallStart(`test`, {})
bridge.onToolCallStart(`call-test`, `test`, {})
expect(writes[2]!.key).toBe(`tc-6`)
expect((writes[2]!.value as Record<string, unknown>).run_id).toBe(`run-3`)
})
Expand All @@ -169,7 +195,7 @@ describe(`createOutboundBridge`, () => {
bridge.onRunStart()
bridge.onStepStart()
bridge.onTextStart()
bridge.onToolCallStart(`search`, {})
bridge.onToolCallStart(`call-search`, `search`, {})

expect(writes[0]!.key).toBe(`run-2`)
expect(writes[1]!.key).toBe(`step-4`)
Expand Down Expand Up @@ -228,7 +254,9 @@ describe(`createOutboundBridge`, () => {

bridge.onRunStart()
bridge.onRunEnd()
expect(() => bridge.onToolCallStart(`search`, {})).toThrow(/active run/i)
expect(() => bridge.onToolCallStart(`call-search`, `search`, {})).toThrow(
/active run/i
)
})

it(`rejects step start after run ends`, () => {
Expand Down
Loading
Loading