From 98b0798f60d528adb8f62bdaed6e0831e7cd8111 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:20:45 +0000 Subject: [PATCH] feat(coach): main-bot coach tools and goals in its prompt The main agent had no way to record what the user wants to achieve or how their day went, and its prompt never showed their goals or habits. - update_goal, update_habit, log_check_in and write_daily_brief, main-bot only and, like update_user_profile, withheld from shared channels and from runs that answer another owner's agent. Goals and habits can be named by id or exact title. - The main bot's own runs list active goals and habits (with ids, done state and streak) in the block; the basics-only block and every other bot never see them. The main agent role adds a short coaching stance and names each coach tool only when the run has it, so the check-in routine prompts stay tool-agnostic. - The scripted runtime understands a few coach commands and the morning check-in, so tests can drive the tools end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/builtin-tools.ts | 2 + packages/adapters/src/coach-rhythm.ts | 2 +- packages/adapters/src/coach-tools.test.ts | 79 ++++ packages/adapters/src/coach-tools.ts | 354 ++++++++++++++++++ .../src/executor-capabilities.test.ts | 3 + .../src/executor-instructions.test.ts | 62 +++ packages/adapters/src/executor-prompt.ts | 55 ++- packages/adapters/src/executor-tools.test.ts | 9 +- packages/adapters/src/executor-tools.ts | 19 +- packages/adapters/src/executor.ts | 14 +- packages/adapters/src/scripted-runtime.ts | 53 +++ 11 files changed, 639 insertions(+), 13 deletions(-) create mode 100644 packages/adapters/src/coach-tools.test.ts create mode 100644 packages/adapters/src/coach-tools.ts 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 =