Compare commits

..

1 Commits

Author SHA1 Message Date
1bf685fcbf feat: enable reminders by default 2026-06-14 21:03:41 +01:00
11 changed files with 172 additions and 94 deletions

View File

@@ -15,8 +15,20 @@ 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 {
@@ -175,6 +187,7 @@ async function askAgent(backendUrl: string, cookies: CookieJar, message: string)
}
console.log(`\nagent> ${data.message || "(no message)"}`)
printProposedActions(data.proposedActions)
console.log("")
}
@@ -353,6 +366,22 @@ 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,
@@ -550,7 +579,9 @@ function isAuthSession(value: unknown): value is AuthSession {
function isQueryResponse(value: unknown): value is QueryResponse {
if (!isJsonObject(value)) return false
return typeof value.message === "string"
if (typeof value.message !== "string") return false
if (!Array.isArray(value.proposedActions)) return false
return value.proposedActions.every(isProposedAction)
}
function isQueryToolsResponse(value: unknown): value is QueryToolsResponse {
@@ -585,6 +616,20 @@ 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,28 +57,6 @@ 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() {
@@ -131,16 +109,6 @@ 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,6 +1,7 @@
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>
@@ -22,7 +23,7 @@ const FreyaGetContextTool = "freya_get_context"
const FreyaListContextTool = "freya_list_context"
const FreyaGetSourceDataTool = "freya_get_source_data"
const FreyaGetFeedItemTool = "freya_get_feed_item"
const FreyaExecuteActionTool = "freya_execute_action"
const FreyaProposeActionTool = "freya_propose_action"
export function createQueryDebugTools(sessionManager: UserSessionManager): QueryDebugTools {
return new DefaultQueryDebugTools(sessionManager)
@@ -85,12 +86,14 @@ class DefaultQueryDebugTools implements QueryDebugTools {
},
},
{
name: FreyaExecuteActionTool,
label: "Execute FREYA Action",
description: "Execute an available source action immediately.",
name: FreyaProposeActionTool,
label: "Propose FREYA Action",
description: "Create a proposed action object without executing it.",
parameters: {
sourceId: "string",
actionId: "string",
title: "string",
description: "string",
sourceId: "string?",
actionId: "string?",
params: "unknown?",
},
},
@@ -111,8 +114,8 @@ class DefaultQueryDebugTools implements QueryDebugTools {
return this.listContext(userId)
case FreyaGetSourceDataTool:
return this.getSourceData(userId, expectToolParams(params, ["sourceId"]))
case FreyaExecuteActionTool:
return this.executeAction(userId, expectToolParams(params, ["sourceId", "actionId"]))
case FreyaProposeActionTool:
return proposeAction(expectToolParams(params, ["title", "description"]))
default:
throw new Error(`Unknown debug tool: ${toolName}`)
}
@@ -319,20 +322,27 @@ class DefaultQueryDebugTools implements QueryDebugTools {
errors,
}
}
}
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)
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 } : {}),
}
return {
ok: true,
sourceId,
actionId,
result: result ?? null,
}
return {
ok: true,
proposedActionId: action.id,
requiresConfirmation: true,
proposedAction: action,
}
}

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 { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
import { registerAgentHttpHandlers, registerDebugAgentHttpHandlers } from "./http.ts"
@@ -80,10 +80,21 @@ describe("POST /api/agent", () => {
expect(res.status).toBe(401)
})
test("collects text deltas", async () => {
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",
}
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")
@@ -101,8 +112,10 @@ 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 { QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import type { ProposedAction, QueryAgent, QueryAgentAsk, QueryAgentEvent } from "./query-agent.ts"
import { InMemoryResourceLoader } from "./in-memory-resource-loader.ts"
import defaultSystemPrompt from "./prompts/system.txt"
@@ -28,18 +28,24 @@ 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, symbol>()
private readonly activeRuns = new Map<string, ActiveRun>()
constructor(config: PiQueryAgentConfig) {
this.sessionManager = config.sessionManager
@@ -48,6 +54,7 @@ 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> {
@@ -59,7 +66,7 @@ export class PiQueryAgent implements QueryAgent {
return
}
const run = Symbol(input.userId)
const run: ActiveRun = { proposedActions: [] }
this.activeRuns.set(input.userId, run)
let session: PiSession
@@ -110,6 +117,9 @@ 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) => {
@@ -151,7 +161,7 @@ export class PiQueryAgent implements QueryAgent {
this.activeRuns.clear()
}
private clearActiveRun(userId: string, run: symbol): void {
private clearActiveRun(userId: string, run: ActiveRun): void {
if (this.activeRuns.get(userId) === run) {
this.activeRuns.delete(userId)
}
@@ -204,6 +214,10 @@ 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,7 +1,5 @@
<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>
@@ -17,9 +15,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_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.
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.
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}.
if you need more information to answer user's query, call freya_propose_action with freya.web-search source id.
</action>
<behavior>

View File

@@ -3,10 +3,22 @@ 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 }
@@ -18,6 +30,7 @@ export interface QueryAgent {
export interface QueryAgentResponse {
message: string
proposedActions: ProposedAction[]
}
export class QueryAgentError extends Error {
@@ -32,12 +45,16 @@ 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":
@@ -47,5 +64,5 @@ export async function collectQueryAgentResponse(
}
}
return { message }
return { message, proposedActions }
}

View File

@@ -3,12 +3,15 @@ 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"
@@ -17,7 +20,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_EXECUTE_ACTION_TOOL = "freya_execute_action"
export const FREYA_PROPOSE_ACTION_TOOL = "freya_propose_action"
export const FREYA_AGENT_TOOL_NAMES = [
FREYA_LIST_SOURCES_TOOL,
@@ -26,7 +29,7 @@ export const FREYA_AGENT_TOOL_NAMES = [
FREYA_QUERY_CONTEXT_TOOL,
FREYA_LIST_CONTEXT_TOOL,
FREYA_GET_SOURCE_DATA_TOOL,
FREYA_EXECUTE_ACTION_TOOL,
FREYA_PROPOSE_ACTION_TOOL,
]
export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
@@ -118,21 +121,28 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
execute: async (_toolCallId, params) => executeGetSourceDataTool(config, params),
})
const executeActionTool = defineTool({
name: FREYA_EXECUTE_ACTION_TOOL,
label: "Execute FREYA Action",
description:
"Execute an available FREYA source action immediately without creating a proposal.",
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.",
parameters: Type.Object({
sourceId: Type.String({ description: "Source ID that should execute the action." }),
actionId: Type.String({ description: "Source action ID to execute." }),
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." }),
),
params: Type.Optional(
Type.Unknown({
description: "Parameters to pass to the source action.",
description: "Parameters to pass to the source action after confirmation.",
}),
),
}),
execute: async (_toolCallId, params) => executeActionToolCall(config, params),
execute: async (_toolCallId, params) => executeProposeActionTool(config, params),
})
return [
@@ -142,7 +152,7 @@ export function createFreyaAgentTools(config: CreateFreyaAgentToolsConfig) {
queryContextTool,
listContextTool,
getSourceDataTool,
executeActionTool,
proposeActionTool,
]
}
@@ -275,37 +285,40 @@ async function executeGetSourceDataTool(
}
}
async function executeActionToolCall(
function executeProposeActionTool(
config: CreateFreyaAgentToolsConfig,
params: {
sourceId: string
actionId: string
title: string
description: string
sourceId?: string
actionId?: string
params?: unknown
},
) {
const userSession = await config.sessionManager.getOrCreate(config.userId)
const result = await userSession.engine.executeAction(
params.sourceId,
params.actionId,
params.params,
)
const actionExecution = {
sourceId: params.sourceId,
actionId: params.actionId,
result: result ?? null,
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 } : {}),
}
config.proposeAction(action)
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
...actionExecution,
proposedActionId: action.id,
requiresConfirmation: true,
}),
},
],
details: { actionExecution },
details: { proposedAction: action },
}
}

View File

@@ -37,13 +37,13 @@ export function meta({}: Route.MetaArgs) {
},
{ property: "og:title", content: PAGE_TITLE },
{ property: "og:description", content: PAGE_DESCRIPTION },
{ property: "og:image", content: "https://ael.is/social-media-preview.jpg" },
{ property: "og:image", content: "https://ael.is/social-media-preview.png" },
{ property: "og:url", content: "https://ael.is" },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: PAGE_TITLE },
{ name: "twitter:description", content: PAGE_DESCRIPTION },
{ name: "twitter:image", content: "https://ael.is/social-media-preview.jpg" },
{ name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB