Compare commits

..

2 Commits

Author SHA1 Message Date
fc443d967d feat: execute agent actions (#134) 2026-06-14 23:08:28 +01:00
9836d7499b feat: enable reminders by default (#133) 2026-06-14 22:38:26 +01:00
8 changed files with 92 additions and 170 deletions

View File

@@ -15,20 +15,8 @@ interface AuthSession {
}
}
interface ProposedAction {
id: string
title: string
description: string
sourceId?: string
actionId?: string
params?: unknown
requiresConfirmation: true
createdAt: string
}
interface QueryResponse {
message: string
proposedActions: ProposedAction[]
}
interface QueryToolDefinition {
@@ -187,7 +175,6 @@ async function askAgent(backendUrl: string, cookies: CookieJar, message: string)
}
console.log(`\nagent> ${data.message || "(no message)"}`)
printProposedActions(data.proposedActions)
console.log("")
}
@@ -366,22 +353,6 @@ function printHelp(): void {
console.log(" /quit Exit\n")
}
function printProposedActions(actions: ProposedAction[]): void {
if (actions.length === 0) return
console.log("\nProposed actions:")
for (const action of actions) {
console.log(`- ${action.title} (${action.id})`)
console.log(` ${action.description}`)
if (action.sourceId || action.actionId) {
console.log(` source=${action.sourceId ?? "-"} action=${action.actionId ?? "-"}`)
}
if (action.params !== undefined) {
console.log(` params=${JSON.stringify(action.params)}`)
}
}
}
function askRequired(
label: string,
defaultValue?: string,
@@ -579,9 +550,7 @@ function isAuthSession(value: unknown): value is AuthSession {
function isQueryResponse(value: unknown): value is QueryResponse {
if (!isJsonObject(value)) return false
if (typeof value.message !== "string") return false
if (!Array.isArray(value.proposedActions)) return false
return value.proposedActions.every(isProposedAction)
return typeof value.message === "string"
}
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
@@ -616,20 +585,6 @@ function isSourceActionDefinition(value: unknown): value is { id: string; descri
)
}
function isProposedAction(value: unknown): value is ProposedAction {
if (!isJsonObject(value)) return false
return (
typeof value.id === "string" &&
typeof value.title === "string" &&
typeof value.description === "string" &&
(value.sourceId === undefined || typeof value.sourceId === "string") &&
(value.actionId === undefined || typeof value.actionId === "string") &&
value.requiresConfirmation === true &&
typeof value.createdAt === "string"
)
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View File

@@ -57,6 +57,28 @@ describe("query debug tools", () => {
},
])
})
test("executes source action directly", async () => {
const tools = createTestDebugTools()
const params = { title: "Buy tea" }
const result = await tools.execute("user-1", "freya_execute_action", {
sourceId: "freya.reminders",
actionId: "create-reminder",
params,
})
expect(result).toEqual({
ok: true,
sourceId: "freya.reminders",
actionId: "create-reminder",
result: {
sourceId: "freya.reminders",
actionId: "create-reminder",
params,
},
})
})
})
function createTestDebugTools() {
@@ -109,6 +131,16 @@ function createTestDebugTools() {
async listActions(sourceId: string) {
return actions[sourceId] ?? {}
},
async executeAction(sourceId: string, actionId: string, params: unknown) {
const sourceActions = actions[sourceId]
if (!sourceActions) {
throw new Error(`Source not found: ${sourceId}`)
}
if (!(actionId in sourceActions)) {
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
}
return { sourceId, actionId, params }
},
},
hasSource(sourceId: string) {
return sourceId in actions

View File

@@ -1,7 +1,6 @@
import { contextKey, type ContextKeyPart } from "@freya/core"
import type { UserSessionManager } from "../session/index.ts"
import type { ProposedAction } from "./query-agent.ts"
type ToolParams = Record<string, unknown>
@@ -23,7 +22,7 @@ const FreyaGetContextTool = "freya_get_context"
const FreyaListContextTool = "freya_list_context"
const FreyaGetSourceDataTool = "freya_get_source_data"
const FreyaGetFeedItemTool = "freya_get_feed_item"
const FreyaProposeActionTool = "freya_propose_action"
const FreyaExecuteActionTool = "freya_execute_action"
export function createQueryDebugTools(sessionManager: UserSessionManager): QueryDebugTools {
return new DefaultQueryDebugTools(sessionManager)
@@ -86,14 +85,12 @@ class DefaultQueryDebugTools implements QueryDebugTools {
},
},
{
name: FreyaProposeActionTool,
label: "Propose FREYA Action",
description: "Create a proposed action object without executing it.",
name: FreyaExecuteActionTool,
label: "Execute FREYA Action",
description: "Execute an available source action immediately.",
parameters: {
title: "string",
description: "string",
sourceId: "string?",
actionId: "string?",
sourceId: "string",
actionId: "string",
params: "unknown?",
},
},
@@ -114,8 +111,8 @@ class DefaultQueryDebugTools implements QueryDebugTools {
return this.listContext(userId)
case FreyaGetSourceDataTool:
return this.getSourceData(userId, expectToolParams(params, ["sourceId"]))
case FreyaProposeActionTool:
return proposeAction(expectToolParams(params, ["title", "description"]))
case FreyaExecuteActionTool:
return this.executeAction(userId, expectToolParams(params, ["sourceId", "actionId"]))
default:
throw new Error(`Unknown debug tool: ${toolName}`)
}
@@ -322,27 +319,20 @@ class DefaultQueryDebugTools implements QueryDebugTools {
errors,
}
}
}
function proposeAction(params: ToolParams): unknown {
const sourceId = optionalString(params, "sourceId")
const actionId = optionalString(params, "actionId")
const action: ProposedAction = {
id: crypto.randomUUID(),
title: expectString(params, "title"),
description: expectString(params, "description"),
requiresConfirmation: true,
createdAt: new Date().toISOString(),
...(sourceId ? { sourceId } : {}),
...(actionId ? { actionId } : {}),
...("params" in params ? { params: params.params } : {}),
}
private async executeAction(userId: string, params: ToolParams): Promise<unknown> {
const sourceId = expectString(params, "sourceId")
const actionId = expectString(params, "actionId")
const actionParams = "params" in params ? params.params : undefined
const userSession = await this.sessionManager.getOrCreate(userId)
const result = await userSession.engine.executeAction(sourceId, actionId, actionParams)
return {
ok: true,
proposedActionId: action.id,
requiresConfirmation: true,
proposedAction: action,
sourceId,
actionId,
result: result ?? null,
}
}
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Hono } from "hono"
import type { QueryDebugTools, QueryDebugToolDefinition } from "./debug-tools.ts"
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts"
@@ -80,21 +80,10 @@ describe("POST /api/agent", () => {
expect(res.status).toBe(401)
})
test("collects text deltas and proposed actions", async () => {
const action: ProposedAction = {
id: "proposal-1",
title: "Update commute line",
description: "Set the user's commute line to Victoria.",
sourceId: "freya.tfl",
actionId: "set-lines-of-interest",
params: ["victoria"],
requiresConfirmation: true,
createdAt: "2026-06-12T12:00:00.000Z",
}
test("collects text deltas", async () => {
const agent = new FakeQueryAgent([
{ type: "text_delta", text: "You should " },
{ type: "text_delta", text: "leave at 8:30." },
{ type: "action_proposed", action },
{ type: "done" },
])
const app = buildTestApp(agent, "user-1")
@@ -112,10 +101,8 @@ describe("POST /api/agent", () => {
const body = (await res.json()) as {
message: string
proposedActions: ProposedAction[]
}
expect(body.message).toBe("You should leave at 8:30.")
expect(body.proposedActions).toEqual([action])
})
test("returns 400 for invalid body", async () => {

View File

@@ -10,7 +10,7 @@ import {
import { tmpdir } from "node:os"
import type { UserSessionManager } from "../session/index.ts"
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
import defaultSystemPrompt from "./prompts/system.txt"
@@ -28,24 +28,18 @@ export interface PiQueryAgentConfig {
apiKey?: string
cwd?: string
systemPrompt?: string
clock?: () => Date
}
interface ActiveRun {
proposedActions: ProposedAction[]
}
export class PiQueryAgent implements QueryAgent {
private readonly sessionManager: UserSessionManager
private readonly cwd: string
private readonly systemPrompt: string
private readonly clock: () => Date
private readonly modelProvider: string
private readonly modelId: string
private readonly apiKey: string | undefined
private readonly sessions = new Map<string, PiSession>()
private readonly pendingSessions = new Map<string, Promise<PiSession>>()
private readonly activeRuns = new Map<string, ActiveRun>()
private readonly activeRuns = new Map<string, symbol>()
constructor(config: PiQueryAgentConfig) {
this.sessionManager = config.sessionManager
@@ -54,7 +48,6 @@ export class PiQueryAgent implements QueryAgent {
this.apiKey = config.apiKey
this.cwd = config.cwd ?? tmpdir()
this.systemPrompt = config.systemPrompt ?? defaultSystemPrompt
this.clock = config.clock ?? (() => new Date())
}
async *ask(input: QueryAgentAsk): AsyncIterable<QueryAgentEvent> {
@@ -66,7 +59,7 @@ export class PiQueryAgent implements QueryAgent {
return
}
const run: ActiveRun = { proposedActions: [] }
const run = Symbol(input.userId)
this.activeRuns.set(input.userId, run)
let session: PiSession
@@ -117,9 +110,6 @@ export class PiQueryAgent implements QueryAgent {
void this.runPrompt(session, input)
.then(() => {
if (runFailed) return
for (const action of run.proposedActions) {
pushRunEvent({ type: "action_proposed", action })
}
pushRunEvent({ type: "done" })
})
.catch((err: unknown) => {
@@ -161,7 +151,7 @@ export class PiQueryAgent implements QueryAgent {
this.activeRuns.clear()
}
private clearActiveRun(userId: string, run: ActiveRun): void {
private clearActiveRun(userId: string, run: symbol): void {
if (this.activeRuns.get(userId) === run) {
this.activeRuns.delete(userId)
}
@@ -214,10 +204,6 @@ export class PiQueryAgent implements QueryAgent {
customTools: createFreyaAgentTools({
userId,
sessionManager: this.sessionManager,
clock: this.clock,
proposeAction: (action) => {
this.activeRuns.get(userId)?.proposedActions.push(action)
},
}),
tools: [...FREYA_AGENT_TOOL_NAMES],
})

View File

@@ -1,5 +1,7 @@
<identity>
You are Freya. You are a digital companion created by Kenneth. His twitter is @kennethnym.
You have access to user data via the context graph. It stores the latest snapshot of all user data and context.
It reactively updates based on external events, such as, but not exclusively, when the user moves, when a new email arrives, when weather updates are available, and when transit alerts are issued.
</identity>
<action>
@@ -15,9 +17,9 @@ freya_list_context: when you need to inspect all current context graph entries.
freya_get_source_data: when you need current feed items, context entries, actions, or errors for a specific source ID.
freya_propose_action: when the user asks to change state or when you recommend a concrete action that should be confirmed first. This tool only proposes an action. It does not execute the action.
freya_execute_action: when the user asks you to perform an available source action, or when the source action is non-mutating and tool-like. This executes immediately.
if you need more information to answer user's query, call freya_propose_action with freya.web-search source id.
If you need more information to answer user's query, call freya_execute_action with sourceId "freya.web-search", actionId "search", and params containing query and numResults, for example {"query":"latest relevant information","numResults":5}.
</action>
<behavior>

View File

@@ -3,22 +3,10 @@ export interface QueryAgentAsk {
message: string
}
export interface ProposedAction {
id: string
title: string
description: string
sourceId?: string
actionId?: string
params?: unknown
requiresConfirmation: true
createdAt: string
}
export type QueryAgentEvent =
| { type: "text_delta"; text: string }
| { type: "tool_start"; toolName: string }
| { type: "tool_end"; toolName: string; ok: boolean }
| { type: "action_proposed"; action: ProposedAction }
| { type: "done" }
| { type: "error"; message: string }
@@ -30,7 +18,6 @@ export interface QueryAgent {
export interface QueryAgentResponse {
message: string
proposedActions: ProposedAction[]
}
export class QueryAgentError extends Error {
@@ -45,16 +32,12 @@ export async function collectQueryAgentResponse(
input: QueryAgentAsk,
): Promise<QueryAgentResponse> {
let message = ""
const proposedActions: ProposedAction[] = []
for await (const event of agent.ask(input)) {
switch (event.type) {
case "text_delta":
message += event.text
break
case "action_proposed":
proposedActions.push(event.action)
break
case "error":
throw new QueryAgentError(event.message)
case "tool_start":
@@ -64,5 +47,5 @@ export async function collectQueryAgentResponse(
}
}
return { message, proposedActions }
return { message }
}

View File

@@ -3,15 +3,12 @@ import { Type } from "typebox"
import type { UserSessionManager } from "../session/index.ts"
import type { QueryDebugTools } from "./debug-tools.ts"
import type { ProposedAction } from "./query-agent.ts"
import { createQueryDebugTools } from "./debug-tools.ts"
interface CreateFreyaAgentToolsConfig {
userId: string
sessionManager: UserSessionManager
clock: () => Date
proposeAction(action: ProposedAction): void
}
export const FREYA_QUERY_CONTEXT_TOOL = "freya_query_context"
@@ -20,7 +17,7 @@ export const FREYA_GET_CONTEXT_TOOL = "freya_get_context"
export const FREYA_LIST_CONTEXT_TOOL = "freya_list_context"
export const FREYA_GET_SOURCE_DATA_TOOL = "freya_get_source_data"
export const FREYA_GET_FEED_ITEM_TOOL = "freya_get_feed_item"
export const FREYA_PROPOSE_ACTION_TOOL = "freya_propose_action"
export const FREYA_EXECUTE_ACTION_TOOL = "freya_execute_action"
export const FREYA_AGENT_TOOL_NAMES = [
FREYA_LIST_SOURCES_TOOL,
@@ -29,7 +26,7 @@ export const FREYA_AGENT_TOOL_NAMES = [
FREYA_QUERY_CONTEXT_TOOL,
FREYA_LIST_CONTEXT_TOOL,
FREYA_GET_SOURCE_DATA_TOOL,
FREYA_PROPOSE_ACTION_TOOL,
FREYA_EXECUTE_ACTION_TOOL,
]
export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
@@ -121,28 +118,21 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params),
})
const proposeActionTool = defineTool({
name: FREYA_PROPOSE_ACTION_TOOL,
label: "Propose FREYA Action",
description: "Create a proposed action for the user to review. This never executes the action.",
const executeActionTool = defineTool({
name: FREYA_EXECUTE_ACTION_TOOL,
label: "Execute FREYA Action",
description:
"Execute an available FREYA source action immediately without creating a proposal.",
parameters: Type.Object({
title: Type.String({ description: "Short user-facing action title." }),
description: Type.String({
description: "What will happen if the user confirms this action.",
}),
sourceId: Type.Optional(
Type.String({ description: "Source ID that should execute the action, if known." }),
),
actionId: Type.Optional(
Type.String({ description: "Source action ID to execute after confirmation, if known." }),
),
sourceId: Type.String({ description: "Source ID that should execute the action." }),
actionId: Type.String({ description: "Source action ID to execute." }),
params: Type.Optional(
Type.Unknown({
description: "Parameters to pass to the source action after confirmation.",
description: "Parameters to pass to the source action.",
}),
),
}),
execute: async (_toolCallId, params) => executeProposeActionTool(config, params),
execute: async (_toolCallId, params) => executeActionToolCall(config, params),
})
return [
@@ -152,7 +142,7 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
queryContextTool,
listContextTool,
getSourceDataTool,
proposeActionTool,
executeActionTool,
]
}
@@ -285,28 +275,26 @@ async function executeGetSourceDataTool(
}
}
function executeProposeActionTool(
async function executeActionToolCall(
config: CreateFreyaAgentToolsConfig,
params: {
title: string
description: string
sourceId?: string
actionId?: string
sourceId: string
actionId: string
params?: unknown
},
) {
const action: ProposedAction = {
id: crypto.randomUUID(),
title: params.title,
description: params.description,
requiresConfirmation: true,
createdAt: config.clock().toISOString(),
...(params.sourceId ? { sourceId: params.sourceId } : {}),
...(params.actionId ? { actionId: params.actionId } : {}),
...(params.params !== undefined ? { params: params.params } : {}),
}
const userSession = await config.sessionManager.getOrCreate(config.userId)
const result = await userSession.engine.executeAction(
params.sourceId,
params.actionId,
params.params,
)
config.proposeAction(action)
const actionExecution = {
sourceId: params.sourceId,
actionId: params.actionId,
result: result ?? null,
}
return {
content: [
@@ -314,11 +302,10 @@ function executeProposeActionTool(
type: "text" as const,
text: JSON.stringify({
ok: true,
proposedActionId: action.id,
requiresConfirmation: true,
...actionExecution,
}),
},
],
details: { proposedAction: action },
details: { actionExecution },
}
}