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 <user> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
e1ce6f4090
commit
98b0798f60
@ -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:
|
||||
|
||||
@ -34,7 +34,7 @@ export const COACH_ROUTINES: Record<CoachKey, { name: string; prompt: string }>
|
||||
|
||||
export interface CoachRhythmDeps {
|
||||
prisma: PrismaClient;
|
||||
jobs: JobPublisher;
|
||||
jobs: Pick<JobPublisher, "enqueue" | "cancel">;
|
||||
}
|
||||
|
||||
/** The cron for one check-in, or null when it is off. */
|
||||
|
||||
79
packages/adapters/src/coach-tools.test.ts
Normal file
79
packages/adapters/src/coach-tools.test.ts
Normal file
@ -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") });
|
||||
});
|
||||
});
|
||||
354
packages/adapters/src/coach-tools.ts
Normal file
354
packages/adapters/src/coach-tools.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<Array<{ id: string }>>,
|
||||
ref: string,
|
||||
what: "goal" | "habit",
|
||||
): Promise<string | ToolResult> {
|
||||
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<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
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<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
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<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
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<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
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<string, unknown>,
|
||||
) => Promise<ToolResult>
|
||||
> = {
|
||||
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<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -111,6 +111,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record<stri
|
||||
user: { findUnique: vi.fn(async () => ({ 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;
|
||||
|
||||
@ -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<string, unknown> | null;
|
||||
facts?: Array<{ category: string; value: string; confidence: number }>;
|
||||
goals?: Array<Record<string, unknown>>;
|
||||
habits?: Array<Record<string, unknown>>;
|
||||
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</user>`,
|
||||
);
|
||||
|
||||
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: "" },
|
||||
|
||||
@ -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>): 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<string>): Array<string | undefined> {
|
||||
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<CoachSummary, "goals" | "habits">;
|
||||
} = {},
|
||||
): 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<T>(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<CoachSummary, "goals" | "habits">): 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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<string> = new Set<string>([
|
||||
"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<string> = new Set<string>([
|
||||
"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(
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<string, AbortController>();
|
||||
|
||||
@ -15,6 +16,56 @@ const LEARN_ABOUT_ME = new RegExp(
|
||||
"i",
|
||||
);
|
||||
|
||||
/** "set a goal: …", "track a habit: …", "done today: <habit>" and "write today's brief: …". */
|
||||
const COACH_COMMANDS: Array<{
|
||||
pattern: RegExp;
|
||||
reply: string;
|
||||
call: (value: string) => { name: string; args: Record<string, unknown> };
|
||||
}> = [
|
||||
{
|
||||
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<AgentRunRequest["script"]> | 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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user