Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/routines-tools/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
56 changes: 51 additions & 5 deletions packages/routines-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,13 +43,14 @@ function routineViewBody(overrides: Partial<Record<string, unknown>> = {}) {
};
}

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,
]);
});
Expand All @@ -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" },
]);
});
Expand Down Expand Up @@ -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" })),
Expand All @@ -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;
}
Expand Down
122 changes: 92 additions & 30 deletions packages/routines-tools/src/tool.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown>",
"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<string, unknown>",
Expand Down Expand Up @@ -341,15 +356,11 @@ async function runRoutineUpdate(
const patch: {
enabled?: boolean;
name?: string;
definitionAssetId?: string;
trigger?: RoutineTriggerInput;
input?: Record<string, unknown>;
} = {};
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;
}
Expand All @@ -367,6 +378,35 @@ async function runRoutineUpdate(
}
}

async function runRoutineRetarget(
env: WorkflowRoutineEnv,
call: ToolCall,
): Promise<ToolResult> {
const parsed = RoutineRetargetInput(
call.arguments as Record<string, unknown>,
);
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,
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -476,6 +518,7 @@ export const routinesTools = defineTool<WorkflowRoutineEnv>({
{ 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) => ({
Expand Down Expand Up @@ -554,11 +597,10 @@ export const routinesTools = defineTool<WorkflowRoutineEnv>({
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: {
Expand All @@ -571,13 +613,6 @@ export const routinesTools = defineTool<WorkflowRoutineEnv>({
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:
Expand All @@ -595,6 +630,31 @@ export const routinesTools = defineTool<WorkflowRoutineEnv>({
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:
Expand Down Expand Up @@ -627,6 +687,8 @@ export const routinesTools = defineTool<WorkflowRoutineEnv>({
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:
Expand Down
8 changes: 5 additions & 3 deletions packages/routines/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion scripts/checks/report-error-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion workflows/assistant/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading