diff --git a/packages/routines-tools/package.json b/packages/routines-tools/package.json index 76bf1dad3..db036bfee 100644 --- a/packages/routines-tools/package.json +++ b/packages/routines-tools/package.json @@ -1,8 +1,8 @@ { "name": "@corbits/routines-tools", "private": true, - "description": "Myra's routine-management tool bundle (routine_list, routine_create, routine_update, routine_run_now): an @intx/agent tool bundle calling @corbits/routines' workflow-run-authenticated routine routes, so Myra can create and manage the workbench's recurring/triggered automations from chat without reimplementing scheduling, cron, or launch logic", - "version": "0.0.9", + "description": "Myra's routine-management tool bundle (routine_list, routine_create, routine_update, routine_retarget, routine_run_now): an @intx/agent tool bundle calling @corbits/routines' workflow-run-authenticated routine routes, so Myra can create and manage the workbench's recurring/triggered automations from chat without reimplementing scheduling, cron, or launch logic", + "version": "0.0.10", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/routines-tools/src/tool.test.ts b/packages/routines-tools/src/tool.test.ts index 1a515b8fc..7e27ef077 100644 --- a/packages/routines-tools/src/tool.test.ts +++ b/packages/routines-tools/src/tool.test.ts @@ -5,6 +5,7 @@ import { routinesTools, ROUTINE_CREATE_TOOL, ROUTINE_LIST_TOOL, + ROUTINE_RETARGET_TOOL, ROUTINE_RUN_NOW_TOOL, ROUTINE_TARGETS_TOOL, ROUTINE_UPDATE_TOOL, @@ -42,13 +43,14 @@ function routineViewBody(overrides: Partial> = {}) { }; } -test("declares exactly the five routine tools", () => { +test("declares exactly the six routine tools", () => { const bundle = routinesTools(testEnv()); expect(bundle.definitions.map((d) => d.name)).toEqual([ ROUTINE_LIST_TOOL, ROUTINE_TARGETS_TOOL, ROUTINE_CREATE_TOOL, ROUTINE_UPDATE_TOOL, + ROUTINE_RETARGET_TOOL, ROUTINE_RUN_NOW_TOOL, ]); }); @@ -68,12 +70,13 @@ test("routine_list has no approval key — a read never needs a human gate", () expect(listDef).toEqual({ name: ROUTINE_LIST_TOOL }); }); -test('routine_create and routine_update grant no credentials and touch nothing external at call time — only routine_run_now, which fires external action immediately, keeps approval: "ask"', () => { +test('routine_create and routine_update grant no credentials and touch nothing external at call time — routine_run_now, which fires external action immediately, and routine_retarget, which repoints what a routine runs, both keep approval: "ask"', () => { expect(routinesTools.definitions).toEqual([ { name: ROUTINE_LIST_TOOL }, { name: ROUTINE_TARGETS_TOOL }, { name: ROUTINE_CREATE_TOOL }, { name: ROUTINE_UPDATE_TOOL }, + { name: ROUTINE_RETARGET_TOOL, approval: "ask" }, { name: ROUTINE_RUN_NOW_TOOL, approval: "ask" }, ]); }); @@ -583,10 +586,44 @@ test("an ambiguous name resolved through routine_targets returns every matching } }); -test("routine_update retargets a routine by posting a resolved definitionAssetId", async () => { +test("routine_update rejects a definitionAssetId argument without calling out — retargeting only goes through routine_retarget", async () => { + const originalFetch = globalThis.fetch; + let called = false; + globalThis.fetch = (async () => { + called = true; + return new Response("unexpected update call", { status: 500 }); + }) as unknown as typeof fetch; + try { + const bundle = routinesTools(testEnv()); + const result = await bundle.run( + callFor(ROUTINE_UPDATE_TOOL, { id: "rtn_1", definitionAssetId: "ast_2" }), + new AbortController().signal, + ); + expect(called).toBe(false); + expect(result.isError).toBe(true); + expect(result.content).toMatch(/invalid input/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("routine_retarget's input schema requires routineId and definitionAssetId", () => { + const bundle = routinesTools(testEnv()); + const definition = bundle.definitions.find( + (d) => d.name === ROUTINE_RETARGET_TOOL, + ) as unknown as { inputSchema: { required: string[] } }; + expect(definition.inputSchema.required).toEqual([ + "routineId", + "definitionAssetId", + ]); +}); + +test("routine_retarget sends the PATCH with the resolved definitionAssetId", async () => { + let seenUrl: string | undefined; let seenBody: unknown; const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + seenUrl = String(url); seenBody = JSON.parse(String(init?.body)); return new Response( JSON.stringify(routineViewBody({ definitionAssetId: "ast_2" })), @@ -595,11 +632,20 @@ test("routine_update retargets a routine by posting a resolved definitionAssetId try { const bundle = routinesTools(testEnv()); const result = await bundle.run( - callFor(ROUTINE_UPDATE_TOOL, { id: "rtn_1", definitionAssetId: "ast_2" }), + callFor(ROUTINE_RETARGET_TOOL, { + routineId: "rtn_1", + definitionAssetId: "ast_2", + }), new AbortController().signal, ); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-routines/routines/rtn_1", + ); expect(seenBody).toEqual({ definitionAssetId: "ast_2" }); expect(result.isError).toBeFalsy(); + expect(result.content).toBe( + 'Retargeted "Morning digest" (rtn_1) to ast_2.', + ); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/routines-tools/src/tool.ts b/packages/routines-tools/src/tool.ts index 61d356b26..a9e4bde19 100644 --- a/packages/routines-tools/src/tool.ts +++ b/packages/routines-tools/src/tool.ts @@ -1,14 +1,17 @@ // The `@corbits/routines-tools` bundle: `routine_list`, `routine_targets`, -// `routine_create`, `routine_update`, and `routine_run_now` — Myra's -// in-chat way to manage the workbench's recurring/triggered automations. +// `routine_create`, `routine_update`, `routine_retarget`, and +// `routine_run_now` — Myra's in-chat way to manage the workbench's +// recurring/triggered automations. // -// Only `routine_run_now` declares `approval: "ask"` (`@intx/agent`'s -// native per-invocation gate): it fires a routine's target agent -// definition immediately, taking whatever external action that -// definition's own already-granted capabilities allow — the reactor -// suspends the call as a pending approval BEFORE this bundle's `run` -// ever executes, renders it in-chat as an approve/deny card, and only -// resumes into `run` once a human allows it, the same shape +// `routine_run_now` and `routine_retarget` declare `approval: "ask"` +// (`@intx/agent`'s native per-invocation gate); `routine_run_now` fires a +// routine's target agent definition immediately, taking whatever external +// action that definition's own already-granted capabilities allow, and +// `routine_retarget` repoints an existing routine at a different +// definition asset — both meaningfully change what a routine will do +// externally, so both park as a pending approval BEFORE this bundle's +// `run` ever executes, rendered in-chat as an approve/deny card, and only +// resume into `run` once a human allows it, the same shape // `@corbits/capability-tools`' `request_capability` uses. // // `routine_create` and `routine_update` (CL-6209) grant no credentials @@ -22,14 +25,16 @@ // `@corbits/memory-tools`' `memory_list`. // // `definitionAssetId` is a required input on `routine_create` and -// `routine_update`, never auto-resolved from a name inside this bundle +// `routine_retarget`, never auto-resolved from a name inside this bundle // or the route behind it (CL-7359): the model resolves a user's // requested workflow to a `definitionAssetId` by calling // `routine_targets` first. When more than one target matches the name // the user gave, this bundle never guesses — it is the model's job (per // each tool's description below) to return the candidates and ask the -// user to choose, not to pick the first match. Retargeting an existing -// routine is `routine_update` called with a new `definitionAssetId`. +// user to choose, not to pick the first match. `routine_update` rejects a +// `definitionAssetId` argument outright (CL-7359): retargeting an existing +// routine at a different definition goes through the approved +// `routine_retarget` tool, never the unapproved `routine_update` path. // // See `./client.ts` for the workflow-run-authenticated routine routes // (`@corbits/routines`' `createWorkflowRoutineRoutes`) this bundle's @@ -52,6 +57,7 @@ export const ROUTINE_LIST_TOOL = "routine_list"; export const ROUTINE_TARGETS_TOOL = "routine_targets"; export const ROUTINE_CREATE_TOOL = "routine_create"; export const ROUTINE_UPDATE_TOOL = "routine_update"; +export const ROUTINE_RETARGET_TOOL = "routine_retarget"; export const ROUTINE_RUN_NOW_TOOL = "routine_run_now"; /** Env this bundle needs beyond `BaseEnv`: the run's hub-reach @@ -98,17 +104,26 @@ const RoutineCreateInput = type({ }); type RoutineCreateInput = typeof RoutineCreateInput.infer; +// `definitionAssetId` is deliberately absent here (CL-7359): retargeting a +// routine at a different definition is only possible through the approved +// `routine_retarget` tool below, never through this one. `.onUndeclaredKey +// ("reject")` turns a `definitionAssetId` sent on this call into a parse +// error rather than a silently-stripped no-op. const RoutineUpdateInput = type({ id: "string > 0", "enabled?": "boolean", "name?": "string > 0", - "definitionAssetId?": "string > 0", "instruction?": "string > 0", "input?": "Record", "trigger?": TriggerInput, -}); +}).onUndeclaredKey("reject"); type RoutineUpdateInput = typeof RoutineUpdateInput.infer; +const RoutineRetargetInput = type({ + routineId: "string > 0", + definitionAssetId: "string > 0", +}); + const RoutineRunNowInput = type({ id: "string > 0", "input?": "Record", @@ -341,15 +356,11 @@ async function runRoutineUpdate( const patch: { enabled?: boolean; name?: string; - definitionAssetId?: string; trigger?: RoutineTriggerInput; input?: Record; } = {}; if (parsed.enabled !== undefined) patch.enabled = parsed.enabled; if (parsed.name !== undefined) patch.name = parsed.name; - if (parsed.definitionAssetId !== undefined) { - patch.definitionAssetId = parsed.definitionAssetId; - } if (parsed.trigger !== undefined) { patch.trigger = parsed.trigger as RoutineTriggerInput; } @@ -367,6 +378,35 @@ async function runRoutineUpdate( } } +async function runRoutineRetarget( + env: WorkflowRoutineEnv, + call: ToolCall, +): Promise { + const parsed = RoutineRetargetInput( + call.arguments as Record, + ); + if (parsed instanceof type.errors) { + return errorResult( + call.id, + invalidInputError(ROUTINE_RETARGET_TOOL, parsed.summary), + ); + } + try { + const routine = await updateRoutine(clientConfig(env), parsed.routineId, { + definitionAssetId: parsed.definitionAssetId, + }); + return { + callId: call.id, + isError: false, + content: `Retargeted "${routine.name}" (${routine.id}) to ${parsed.definitionAssetId}.`, + }; + } catch (err) { + // report-error-ignore: the hub already reported the rejection with its + // own refId; the model needs the typed envelope text to explain or retry. + return errorResult(call.id, err); + } +} + async function runRoutineRunNow( env: WorkflowRoutineEnv, call: ToolCall, @@ -392,6 +432,8 @@ async function runRoutineRunNow( content: `Started run ${result.runId}.`, }; } catch (err) { + // report-error-ignore: the hub already reported the failure with its + // own refId; the model needs the envelope text to explain or retry. return errorResult(call.id, err); } } @@ -476,6 +518,7 @@ export const routinesTools = defineTool({ { name: ROUTINE_TARGETS_TOOL }, { name: ROUTINE_CREATE_TOOL }, { name: ROUTINE_UPDATE_TOOL }, + { name: ROUTINE_RETARGET_TOOL, approval: "ask" }, { name: ROUTINE_RUN_NOW_TOOL, approval: "ask" }, ], factory: (env) => ({ @@ -554,11 +597,10 @@ export const routinesTools = defineTool({ name: ROUTINE_UPDATE_TOOL, description: "Update an existing routine's enabled state, name, " + - "instruction, named input, trigger, or target. To retarget a " + - "routine at a different agent or workflow, resolve the new " + - "target with routine_targets first (asking the user to " + - "choose if the name is ambiguous), then call this with the " + - "resolved definitionAssetId.", + "instruction, named input, or trigger. Does NOT retarget a " + + "routine at a different agent or workflow — this call rejects " + + "a definitionAssetId argument outright. Use routine_retarget " + + "for that, which requires human approval.", inputSchema: { type: "object", properties: { @@ -571,13 +613,6 @@ export const routinesTools = defineTool({ type: "string", description: "A new name for the routine.", }, - definitionAssetId: { - type: "string", - description: - "Retargets the routine at a different workflow asset — " + - "never invented and never a name. Resolve it first with " + - "routine_targets.", - }, instruction: { type: "string", description: @@ -595,6 +630,31 @@ export const routinesTools = defineTool({ required: ["id"], }, }, + { + name: ROUTINE_RETARGET_TOOL, + description: + "Retarget an existing routine at a different workflow or " + + "agent definition. This is the only way to change what a " + + "routine runs — routine_update rejects definitionAssetId. A " + + "human must approve before the retarget takes effect. Resolve " + + "the new target with routine_targets first (asking the user " + + "to choose if the name is ambiguous), then call this with the " + + "resolved definitionAssetId.", + inputSchema: { + type: "object", + properties: { + routineId: { type: "string", description: "The routine's id." }, + definitionAssetId: { + type: "string", + description: + "The workflow asset id to retarget the routine at — " + + "never invented and never a name. Resolve it first with " + + "routine_targets.", + }, + }, + required: ["routineId", "definitionAssetId"], + }, + }, { name: ROUTINE_RUN_NOW_TOOL, description: @@ -627,6 +687,8 @@ export const routinesTools = defineTool({ return runRoutineCreate(env, call); case ROUTINE_UPDATE_TOOL: return runRoutineUpdate(env, call); + case ROUTINE_RETARGET_TOOL: + return runRoutineRetarget(env, call); case ROUTINE_RUN_NOW_TOOL: return runRoutineRunNow(env, call); default: diff --git a/packages/routines/README.md b/packages/routines/README.md index eaf41f534..b39b699de 100644 --- a/packages/routines/README.md +++ b/packages/routines/README.md @@ -32,10 +32,12 @@ scheduled fire) goes through `@corbits/folded-runs`, the same launch core builders only, no `drizzle-orm`/`postgres`/`@intx/hub-api` imports. - `src/migrations.ts` — this package's own `routine_migrations` ledger. -Myra creates routines only through `routine_targets` → +Myra creates and updates routines only through `routine_targets` → `routine_create`/`routine_update` (`@corbits/routines-tools`), the same -tool-call surface a person's own create/retarget request goes through — -never a separate draft/review state machine (deleted, CL-7375). See +tool-call surface a person's own create request goes through — never a +separate draft/review state machine (deleted, CL-7375); retargeting an +existing routine at a different definition goes through the separately +approved `routine_retarget` tool (CL-7359), never `routine_update`. See docs/workflow-model.md. ## Routine targets follow the latest deployed asset diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 0777f862e..67c25e6f4 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -226,7 +226,6 @@ packages/routines-tools/src/tool.ts 1 return once; packages/routines-tools/src/tool.ts 1 return value; packages/routines-tools/src/tool.ts 2 return errorResult(call.id, err); packages/routines-tools/src/tool.ts 3 return errorResult(call.id, err); -packages/routines-tools/src/tool.ts 4 return errorResult(call.id, err); packages/routines/src/cron.ts 1 return false; packages/routines/src/cron.ts 2 return false; packages/routines/src/routes.ts 1 log.error( diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index f83c2d108..65a55c41d 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -46,7 +46,7 @@ export const ASSISTANT_STEP_ID = "assistant"; export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, { name: "@corbits/capability-tools", version: "0.0.5" }, - { name: "@corbits/routines-tools", version: "0.0.9" }, + { name: "@corbits/routines-tools", version: "0.0.10" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.6" }, { name: "@corbits/catalog-tools", version: "0.0.2" },