diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..a6da4d1 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -8,6 +8,7 @@ import { USER_FACT_CATEGORIES, } from "@rakazo/contracts"; import { z } from "zod"; +import { coachTools } from "./coach-tools.js"; export const SUBAGENT_MODEL_TIER_DESCRIPTION = "Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set."; @@ -422,6 +423,7 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + ...coachTools, { name: "web_search", description: diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts index f9ee580..348e26a 100644 --- a/packages/adapters/src/coach-rhythm.ts +++ b/packages/adapters/src/coach-rhythm.ts @@ -34,7 +34,7 @@ export const COACH_ROUTINES: Record export interface CoachRhythmDeps { prisma: PrismaClient; - jobs: JobPublisher; + jobs: Pick; } /** The cron for one check-in, or null when it is off. */ diff --git a/packages/adapters/src/coach-tools.test.ts b/packages/adapters/src/coach-tools.test.ts new file mode 100644 index 0000000..3aa7567 --- /dev/null +++ b/packages/adapters/src/coach-tools.test.ts @@ -0,0 +1,79 @@ +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it } from "vitest"; +import { COACH_TOOL_NAMES, executeCoachTool } from "./coach-tools.js"; +import { selectBuiltinToolsForRun } from "./executor-tools.js"; + +function toolNames(options: { + mainBot?: boolean; + messagingChannelRun?: boolean; + foreignAgentRun?: boolean; + trigger?: string; +}) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: false, + groupId: null, + trigger: options.trigger ?? "user", + semanticMemoryEnabled: false, + messagingChannelRun: options.messagingChannelRun ?? false, + foreignAgentRun: options.foreignAgentRun, + bot: { + kind: options.mainBot ? "main" : "specialist", + restrictBuiltinTools: false, + allowedBuiltinTools: [], + }, + }).map((tool) => tool.name); +} + +/** Any database access fails the test: refusals and invalid input must not reach it. */ +const untouchedPrisma = new Proxy( + {}, + { + get(_target, property) { + throw new Error(`unexpected database access: ${String(property)}`); + }, + }, +) as PrismaClient; + +const context = (kind: string) => ({ userId: "user-1", bot: { id: "bot-1", kind } }); + +describe("coach tools", () => { + it("are the main bot's alone, also in its check-in routines, and never for other people", () => { + for (const name of COACH_TOOL_NAMES) { + expect(toolNames({ mainBot: true })).toContain(name); + expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name); + expect(toolNames({ mainBot: false })).not.toContain(name); + expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name); + expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name); + } + }); + + it("refuse specialists even when they call a tool anyway", async () => { + for (const name of COACH_TOOL_NAMES) { + await expect( + executeCoachTool(untouchedPrisma, context("specialist"), name, { title: "Run" }), + ).resolves.toEqual({ error: "Only the user's main bot can coach the user." }); + } + }); + + it("reject invalid or incomplete arguments before writing", async () => { + const main = context("main"); + await expect(executeCoachTool(untouchedPrisma, main, "update_goal", {})).resolves.toEqual({ + error: "A new goal needs a title.", + }); + await expect( + executeCoachTool(untouchedPrisma, main, "update_goal", { title: "x", target_date: "soon" }), + ).resolves.toEqual({ error: expect.stringContaining("target_date") }); + await expect( + executeCoachTool(untouchedPrisma, main, "update_habit", { title: "Walk", cadence: "hourly" }), + ).resolves.toEqual({ error: expect.stringContaining("cadence") }); + await expect( + executeCoachTool(untouchedPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }), + ).resolves.toEqual({ error: "Log a habit and a goal in separate calls." }); + await expect( + executeCoachTool(untouchedPrisma, main, "log_check_in", { mood: 9 }), + ).resolves.toEqual({ error: expect.stringContaining("mood") }); + await expect( + executeCoachTool(untouchedPrisma, main, "write_daily_brief", { text: " " }), + ).resolves.toEqual({ error: expect.stringContaining("text") }); + }); +}); diff --git a/packages/adapters/src/coach-tools.ts b/packages/adapters/src/coach-tools.ts new file mode 100644 index 0000000..39a496e --- /dev/null +++ b/packages/adapters/src/coach-tools.ts @@ -0,0 +1,354 @@ +import type { ConnectorTool } from "@rakazo/adapter-kit"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + GOAL_STATUSES, + GoalStatusSchema, + HABIT_CADENCES, + HabitCadenceSchema, + LocalDateSchema, +} from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + logCheckIn, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "@rakazo/db"; +import { z } from "zod"; + +/** The main bot's coach tools, in the order they appear in its tool list. */ +export const COACH_TOOL_NAMES = [ + "update_goal", + "update_habit", + "log_check_in", + "write_daily_brief", +] as const; +export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; + +export function isCoachToolName(name: string): name is CoachToolName { + return (COACH_TOOL_NAMES as readonly string[]).includes(name); +} + +const DATE_HINT = "The user's local date, YYYY-MM-DD."; + +export const coachTools: ConnectorTool[] = [ + { + name: "update_goal", + description: + "Track a goal the user committed to. Without id it creates one; with id it changes it, and status done or dropped closes it. Track only what the user stated or agreed to.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the goal to change." }, + title: { type: "string", description: 'A short outcome, e.g. "Run a 10k in November".' }, + detail: { type: "string", description: "Optional context, such as why it matters." }, + status: { type: "string", enum: [...GOAL_STATUSES] }, + target_date: { type: "string", description: `${DATE_HINT} An empty string clears it.` }, + }, + }, + }, + { + name: "update_habit", + description: + "Track a habit the user wants to keep. Without id it creates one; with id it changes it, and active false stops tracking it.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the habit to change." }, + title: { type: "string", description: 'A short practice, e.g. "Walk 20 minutes".' }, + cadence: { type: "string", enum: [...HABIT_CADENCES] }, + goal: { + type: "string", + description: "Id or exact title of the goal it serves. An empty string unlinks it.", + }, + active: { type: "boolean" }, + }, + }, + }, + { + name: "log_check_in", + description: + "Record what the user reported for a day: a habit kept or missed, progress on a goal, or, with neither, how the day went. Logging the same day again updates it.", + inputSchema: { + type: "object", + properties: { + habit: { type: "string", description: "Id or exact title of the habit." }, + goal: { type: "string", description: "Id or exact title of the goal." }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + done: { type: "boolean", description: "Whether the habit was kept." }, + mood: { type: "integer", minimum: 1, maximum: 5 }, + energy: { type: "integer", minimum: 1, maximum: 5 }, + note: { type: "string", description: "The user's words, briefly." }, + }, + }, + }, + { + name: "write_daily_brief", + description: + "Save the user's brief for the day, shown at the top of their Today page. Plain text, two to five short lines. Writing again replaces it.", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + }, + required: ["text"], + }, + }, +]; + +const Ref = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const ClearableDate = z.union([LocalDateSchema, z.literal("")]); + +const UpdateGoalArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + detail: z.string().trim().max(GOAL_DETAIL_MAX_LENGTH).optional(), + status: GoalStatusSchema.optional(), + target_date: ClearableDate.optional(), +}); + +const UpdateHabitArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + cadence: HabitCadenceSchema.optional(), + goal: z.union([Ref, z.literal("")]).optional(), + active: z.boolean().optional(), +}); + +const Rating = z.number().int().min(1).max(5); + +const LogCheckInArgs = z.object({ + habit: Ref.optional(), + goal: Ref.optional(), + date: LocalDateSchema.optional(), + done: z.boolean().optional(), + mood: Rating.optional(), + energy: Rating.optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), +}); + +const WriteDailyBriefArgs = z.object({ + text: z.string().trim().min(1).max(DAILY_BRIEF_MAX_LENGTH), + date: LocalDateSchema.optional(), +}); + +export interface CoachToolContext { + userId: string; + bot: { id: string; kind: string }; +} + +type ToolResult = Record; + +function invalid(name: string, error: z.ZodError): ToolResult { + const issues = error.issues + .map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`) + .join("; "); + return { error: `Invalid ${name} arguments: ${issues}` }; +} + +/** An id first, then an exact title (any case) among the user's items. */ +async function resolveRef( + find: (where: object) => Promise>, + ref: string, + what: "goal" | "habit", +): Promise { + const byId = await find({ id: ref }); + if (byId[0]) return byId[0].id; + const byTitle = await find({ title: { equals: ref, mode: "insensitive" } }); + if (byTitle.length === 1) return byTitle[0]!.id; + return byTitle.length > 1 + ? { error: `Several ${what}s are titled "${ref}". Use the id.` } + : { error: `No ${what} "${ref}".` }; +} + +function goalRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.userGoal.findMany({ + where: { userId, ...where }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "goal", + ); +} + +function habitRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.habit.findMany({ + where: { userId, ...where }, + orderBy: [{ active: "desc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "habit", + ); +} + +async function updateGoalFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateGoalArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_goal", parsed.error); + const args = parsed.data; + const targetDate = args.target_date === undefined ? undefined : args.target_date || null; + if (!args.id) { + if (!args.title) return { error: "A new goal needs a title." }; + const goal = await createGoal(prisma, context.userId, { + title: args.title, + detail: args.detail, + targetDate, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, goal: { id: goal.id, title: goal.title, status: goal.status } }; + } + const goalId = await goalRef(prisma, context.userId, args.id); + if (typeof goalId !== "string") return goalId; + const goal = await updateGoal(prisma, context.userId, goalId, { + title: args.title, + detail: args.detail, + status: args.status, + targetDate, + }); + return { + ok: true, + goal: { id: goal.id, title: goal.title, status: goal.status, targetDate: goal.targetDate }, + }; +} + +async function updateHabitFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateHabitArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_habit", parsed.error); + const args = parsed.data; + let goalId: string | null | undefined; + if (args.goal) { + const resolved = await goalRef(prisma, context.userId, args.goal); + if (typeof resolved !== "string") return resolved; + goalId = resolved; + } else if (args.goal === "") { + goalId = null; + } + if (!args.id) { + if (!args.title) return { error: "A new habit needs a title." }; + const habit = await createHabit(prisma, context.userId, { + title: args.title, + cadence: args.cadence, + goalId, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, habit: { id: habit.id, title: habit.title, cadence: habit.cadence } }; + } + const habitId = await habitRef(prisma, context.userId, args.id); + if (typeof habitId !== "string") return habitId; + const habit = await updateHabit(prisma, context.userId, habitId, { + title: args.title, + cadence: args.cadence, + goalId, + active: args.active, + }); + return { + ok: true, + habit: { id: habit.id, title: habit.title, cadence: habit.cadence, active: habit.active }, + }; +} + +async function logCheckInFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = LogCheckInArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("log_check_in", parsed.error); + const { habit, goal, ...fields } = parsed.data; + if (habit && goal) return { error: "Log a habit and a goal in separate calls." }; + const habitId = habit ? await habitRef(prisma, context.userId, habit) : undefined; + if (habitId !== undefined && typeof habitId !== "string") return habitId; + const goalId = goal ? await goalRef(prisma, context.userId, goal) : undefined; + if (goalId !== undefined && typeof goalId !== "string") return goalId; + const checkIn = await logCheckIn(prisma, context.userId, { + kind: habitId ? "habit" : goalId ? "goal" : "daily", + habitId, + goalId, + ...fields, + createdByBotId: context.bot.id, + }); + return { + ok: true, + checkIn: { + kind: checkIn.kind, + date: checkIn.date, + done: checkIn.done, + mood: checkIn.mood, + energy: checkIn.energy, + }, + }; +} + +async function writeDailyBriefFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = WriteDailyBriefArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("write_daily_brief", parsed.error); + const brief = await upsertDailyBrief(prisma, context.userId, { + text: parsed.data.text, + date: parsed.data.date, + botId: context.bot.id, + }); + return { ok: true, date: brief.date }; +} + +const HANDLERS: Record< + CoachToolName, + ( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, + ) => Promise +> = { + update_goal: updateGoalFromTool, + update_habit: updateHabitFromTool, + log_check_in: logCheckInFromTool, + write_daily_brief: writeDailyBriefFromTool, +}; + +/** Runs one coach tool for the user's main bot; every other bot is refused. */ +export async function executeCoachTool( + prisma: PrismaClient, + context: CoachToolContext, + name: CoachToolName, + rawArgs: Record, +): Promise { + if (context.bot.kind !== "main") { + return { error: "Only the user's main bot can coach the user." }; + } + try { + return await HANDLERS[name](prisma, context, rawArgs); + } catch (error) { + if (error instanceof CoachNotFoundError || error instanceof InvalidCoachInputError) { + return { error: error.message }; + } + throw error; + } +} diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts index 57de9d8..8c953dd 100644 --- a/packages/adapters/src/executor-capabilities.test.ts +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -111,6 +111,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record ({ name: "Ada" })) }, userProfile: { findUnique: vi.fn(async () => null) }, userFact: { findMany: vi.fn(async () => []) }, + // The main bot's goals and habits. + userGoal: { findMany: vi.fn(async () => []) }, + habit: { findMany: vi.fn(async () => []) }, ...models, }; let request: AgentRunRequest | undefined; diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 26c2e89..da9dc0a 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -1,5 +1,6 @@ import type { AgentRunRequest } from "@rakazo/adapter-kit"; import type { MessageBlock } from "@rakazo/contracts"; +import { addDays, localDateIn } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; import { createRunExecutor } from "./executor.js"; @@ -20,6 +21,9 @@ interface FixtureOptions { sender?: { name: string } | null; profile?: Record | null; facts?: Array<{ category: string; value: string; confidence: number }>; + goals?: Array>; + habits?: Array>; + doneCheckIns?: Array<{ habitId: string; date: Date }>; peers?: Array<{ id: string; name: string; @@ -131,6 +135,9 @@ async function runOnce(options: FixtureOptions = {}) { async () => options.facts ?? [{ category: "work", value: "Owns a bakery", confidence: 1 }], ), }, + userGoal: { findMany: vi.fn(async () => options.goals ?? []) }, + habit: { findMany: vi.fn(async () => options.habits ?? []) }, + checkIn: { findMany: vi.fn(async () => options.doneCheckIns ?? []) }, actionApprovalRule: { findMany: vi.fn(async () => []) }, actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) }, externalEffect: { @@ -297,6 +304,11 @@ describe("run instructions", () => { expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile"); expect(request.instructions).not.toContain("update_user_profile"); + for (const name of ["update_goal", "update_habit", "log_check_in", "write_daily_brief"]) { + expect(request.tools.map((tool) => tool.name)).not.toContain(name); + expect(request.instructions).not.toContain(name); + } + expect(request.instructions).not.toContain("Active goals"); }); it("gives runs woken by the user's own bot the full user block", async () => { @@ -338,6 +350,56 @@ describe("run instructions", () => { expect(request.instructions).not.toContain("save lasting facts they share with remember"); }); + it("gives the main bot the user's goals and habits and names its coach tools", async () => { + const today = localDateIn("Europe/Berlin"); + const row = { + source: "chat", + createdByBotId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const coach = { + goals: [ + { + ...row, + id: "goal-1", + title: "Ship the beta", + detail: "", + status: "active", + targetDate: new Date(`${addDays(today, 30)}T00:00:00Z`), + spaceId: null, + completedAt: null, + }, + ], + habits: [ + { ...row, id: "habit-1", title: "Walk", cadence: "daily", goalId: null, active: true }, + { ...row, id: "habit-2", title: "Long run", cadence: "weekly", goalId: null, active: true }, + ], + doneCheckIns: [today, addDays(today, -1)].map((date) => ({ + habitId: "habit-1", + date: new Date(`${date}T00:00:00Z`), + })), + }; + const main = await runOnce({ + bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, + ...coach, + }); + const names = main.request.tools.map((tool) => tool.name); + expect(names).toEqual( + expect.arrayContaining(["update_goal", "update_habit", "log_check_in", "write_daily_brief"]), + ); + expect(main.request.instructions).toContain("You are also their coach"); + expect(main.request.instructions).toContain("save today's brief with write_daily_brief"); + expect(main.request.instructions).toContain( + `Active goals:\n- Ship the beta, due ${addDays(today, 30)} (id: goal-1)\nHabits:\n- Walk: daily, done today, streak 2 (id: habit-1)\n- Long run: weekly, not done this week, streak 0 (id: habit-2)\n`, + ); + + const specialist = await runOnce(coach); + expect(specialist.request.instructions).not.toContain("Active goals"); + expect(specialist.request.instructions).not.toContain("You are also their coach"); + expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal"); + }); + it("shows the main bot every specialist it can delegate to, its own space first", async () => { const { request, peersFindMany } = await runOnce({ bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 479f2dd..09704a4 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -1,9 +1,12 @@ import { escapePromptData, knownTimeZone } from "@rakazo/core"; -import type { UserContext } from "@rakazo/db"; +import type { CoachSummary, UserContext } from "@rakazo/db"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; /** Per-field caps so user-written profile text cannot crowd out the rest of the prompt. */ const USER_FIELD_MAX_CHARS = 500; const USER_SUMMARY_MAX_CHARS = 2_000; +/** Goals and habits listed in the user block; the rest are counted. */ +const COACH_PROMPT_ITEM_LIMIT = 20; export interface BotIdentity { name: string; @@ -41,11 +44,30 @@ export function formatMainAgentRole(toolNames: ReadonlySet): string { : toolNames.has("remember") ? "Keep what you know about the user current: save lasting facts they share with remember." : "Keep what you know about the user current.", + ...formatCoachGuidance(toolNames), ] .filter(Boolean) .join(" "); } +/** How the main bot coaches, naming only the coach tools this run has. */ +function formatCoachGuidance(toolNames: ReadonlySet): Array { + if (!COACH_TOOL_NAMES.some((name) => toolNames.has(name))) return []; + return [ + "You are also their coach: help them reach the goals and keep the habits they chose. Be brief and encouraging, notice progress, and never nag.", + toolNames.has("update_goal") + ? "Record goals they commit to with update_goal, and close them when they are done or dropped." + : undefined, + toolNames.has("update_habit") ? "Track habits they want to keep with update_habit." : undefined, + toolNames.has("log_check_in") + ? "When they tell you how a habit, a goal or their day went, record it with log_check_in." + : undefined, + toolNames.has("write_daily_brief") + ? "At the morning check-in, save today's brief with write_daily_brief." + : undefined, + ]; +} + /** One line of escaped user text, clipped to `max` characters. */ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string { const text = (value ?? "").replace(/\s+/g, " ").trim(); @@ -62,7 +84,11 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): */ export function formatUserContext( user: UserContext, - options: { basicsOnly?: boolean } = {}, + options: { + basicsOnly?: boolean; + /** The main bot's own runs: the user's active goals and habits. */ + coach?: Pick; + } = {}, ): string | undefined { const field = (label: string, value: string | null | undefined, max?: number) => { const text = userText(value, max); @@ -82,6 +108,7 @@ export function formatUserContext( field("Communication style", user.communicationStyle), options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS), ...(facts.length > 0 ? ["Facts:", ...facts] : []), + ...(options.coach && !options.basicsOnly ? coachLines(options.coach) : []), ].filter((line): line is string => Boolean(line)); if (lines.length === 0) return undefined; return [ @@ -92,6 +119,30 @@ export function formatUserContext( ].join("\n"); } +function listed(label: string, items: readonly T[], line: (item: T) => string): string[] { + if (items.length === 0) return []; + const shown = items.slice(0, COACH_PROMPT_ITEM_LIMIT).map(line); + const more = items.length - shown.length; + return [`${label}:`, ...shown, ...(more > 0 ? [`- and ${more} more`] : [])]; +} + +/** Active goals and habits with ids, so coach tools can name them exactly. */ +function coachLines(coach: Pick): string[] { + return [ + ...listed( + "Active goals", + coach.goals, + (goal) => + `- ${userText(goal.title)}${goal.targetDate ? `, due ${goal.targetDate}` : ""} (id: ${goal.id})`, + ), + ...listed("Habits", coach.habits, (habit) => { + const period = habit.cadence === "weekly" ? "this week" : "today"; + const done = habit.doneToday ? `done ${period}` : `not done ${period}`; + return `- ${userText(habit.title)}: ${habit.cadence}, ${done}, streak ${habit.streak} (id: ${habit.id})`; + }), + ]; +} + /** * Background a run_subagent helper shares with the bot that started it: who it works for, * the present moment, and the user block. diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts index d987728..d7b60eb 100644 --- a/packages/adapters/src/executor-tools.test.ts +++ b/packages/adapters/src/executor-tools.test.ts @@ -64,7 +64,14 @@ describe("builtin tools per bot", () => { }); it("lists main-only tools for the main bot alone", () => { - expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]); + expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([ + "delegate_task", + "log_check_in", + "update_goal", + "update_habit", + "update_user_profile", + "write_daily_brief", + ]); const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); for (const name of MAIN_ONLY_BUILTIN_TOOLS) { diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 2e819b8..d19477b 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -1,6 +1,7 @@ import type { ConnectorTool } from "@rakazo/adapter-kit"; import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; import { selectMemoryTools } from "./memory-tools.js"; import { filterImageReturningComputerTools } from "./model-vision.js"; import { @@ -20,6 +21,13 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([ "delegate_task", "update_user_profile", + ...COACH_TOOL_NAMES, +]); + +/** Tools that write what the user's bots know about the user, so only the user may drive them. */ +const USER_MODEL_TOOLS: ReadonlySet = new Set([ + "update_user_profile", + ...COACH_TOOL_NAMES, ]); const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); @@ -105,15 +113,10 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !(options.foreignAgentRun && tool.name === "update_user_profile") && + !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && (!options.messagingChannelRun || - (![ - "remember", - "save_memory", - "recall_memory", - "forget_memory", - "update_user_profile", - ].includes(tool.name) && + (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && + !USER_MODEL_TOOLS.has(tool.name) && !tool.name.startsWith("scratchpad_"))), ); return filterBuiltinToolsForBot( diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..24899ff 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -96,6 +96,7 @@ import { InvalidSpaceNameError, isTooManyDatabaseConnections, listSpaceUserBotIds, + loadCoachSummary, loadRunHistoryMessages, loadUserContext, type McpServer, @@ -175,6 +176,7 @@ import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-fac import { executeCloudAgentTool } from "./cloud-agent-service.js"; import { validCloudAgentArgs } from "./cloud-agent-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { executeCoachTool, isCoachToolName } from "./coach-tools.js"; import { collectLogIds, mergeConnectedPlugins, @@ -2573,6 +2575,11 @@ export function createRunExecutor(deps: ExecutorDeps) { ), ); } + if (isCoachToolName(name)) { + return finish( + await executeCoachTool(deps.prisma, { userId: run.userId, bot }, name, args), + ); + } if (name === "web_search") { return finish(await webSearchFromTool(web, context, args)); } @@ -3689,8 +3696,13 @@ export function createRunExecutor(deps: ExecutorDeps) { : undefined; const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone); // Replies to another owner's agent, like a shared channel, only get the basics. + const basicsOnly = messagingChannelRun || foreignAgentRun; const userInstruction = formatUserContext(userContext, { - basicsOnly: messagingChannelRun || foreignAgentRun, + basicsOnly, + coach: + bot.kind === "main" && !basicsOnly + ? await loadCoachSummary(deps.prisma, run.userId) + : undefined, }); const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined; diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 6a19544..6d66c41 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -6,6 +6,7 @@ import type { } from "@rakazo/adapter-kit"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core"; +import { COACH_ROUTINES } from "./coach-rhythm.js"; const running = new Map(); @@ -15,6 +16,56 @@ const LEARN_ABOUT_ME = new RegExp( "i", ); +/** "set a goal: …", "track a habit: …", "done today: " and "write today's brief: …". */ +const COACH_COMMANDS: Array<{ + pattern: RegExp; + reply: string; + call: (value: string) => { name: string; args: Record }; +}> = [ + { + pattern: /^set a goal:\s*([\s\S]+)$/i, + reply: "goal noted.", + call: (title) => ({ name: "update_goal", args: { title } }), + }, + { + pattern: /^track a habit:\s*([\s\S]+)$/i, + reply: "habit noted.", + call: (title) => ({ name: "update_habit", args: { title } }), + }, + { + pattern: /^done today:\s*([\s\S]+)$/i, + reply: "checked off.", + call: (habit) => ({ name: "log_check_in", args: { habit, done: true } }), + }, + { + pattern: /^write today's brief:\s*([\s\S]+)$/i, + reply: "today's brief is ready.", + call: (text) => ({ name: "write_daily_brief", args: { text } }), + }, +]; + +/** The morning check-in writes a fixed brief so tests can follow it to the Today page. */ +export const SCRIPTED_MORNING_BRIEF = "One thing at a time today."; + +function inferCoachScript(prompt: string): NonNullable | undefined { + if (prompt.startsWith(COACH_ROUTINES.morning.prompt)) { + return [ + { + assistant: "good morning. today's brief is ready.", + toolCalls: [{ name: "write_daily_brief", args: { text: SCRIPTED_MORNING_BRIEF } }], + complete: true, + }, + ]; + } + for (const command of COACH_COMMANDS) { + const value = command.pattern.exec(prompt.trim())?.[1]?.trim(); + if (value) { + return [{ assistant: command.reply, toolCalls: [command.call(value)], complete: true }]; + } + } + return undefined; +} + export class ScriptedAgentRuntime implements AgentRuntime { describe() { return { @@ -207,6 +258,8 @@ export function inferScript( }, ]; } + const coach = inferCoachScript(prompt); + if (coach) return coach; if (lower.includes("delegate to the bot named")) { const name = namedBot(prompt) ?? "Researcher"; const brief =