Skip to content

Commit 82aa1b7

Browse files
committed
fixes
1 parent df5624f commit 82aa1b7

7 files changed

Lines changed: 318 additions & 13 deletions

File tree

packages/agent/src/api/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ import {
209209
DISABLED_TRIGGER_INTERVAL_MS,
210210
normalizeTriggerDraft,
211211
} from "../triggers/scheduling.js";
212+
import { deployTextTriggerWorkflow } from "../triggers/text-to-workflow.js";
212213
import { parseClampedInteger } from "../utils/number-parsing.js";
213214
import { handleAccountsRoutes } from "./accounts-routes.js";
214215
import { handleAgentAdminRoutes } from "./agent-admin-routes.js";
@@ -1924,6 +1925,7 @@ async function handleRequest(
19241925
buildTriggerConfig,
19251926
buildTriggerMetadata,
19261927
normalizeTriggerDraft,
1928+
deployTextTriggerWorkflow,
19271929
DISABLED_TRIGGER_INTERVAL_MS,
19281930
TRIGGER_TASK_NAME,
19291931
TRIGGER_TASK_TAGS: [...TRIGGER_TASK_TAGS],

packages/agent/src/api/trigger-routes.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import type {
1616
TriggerExecutionOptions,
1717
TriggerExecutionResult,
1818
} from "../triggers/runtime.js";
19+
import type {
20+
DeployedTriggerWorkflow,
21+
TextTriggerWorkflowDraft,
22+
} from "../triggers/text-to-workflow.js";
1923
import type {
2024
NormalizedTriggerDraft,
2125
TriggerHealthSnapshot,
@@ -82,6 +86,17 @@ export interface TriggerRouteContext extends RouteRequestContext {
8286
input: TriggerDraftInput;
8387
fallback: NormalizeTriggerDraftFallback;
8488
}) => { draft?: NormalizedTriggerDraft; error?: string };
89+
/**
90+
* Phase 2E: every persisted trigger is `kind: "workflow"`. When the
91+
* caller submits `kind: "text"` (or omits `kind`), the route uses this
92+
* to materialize a single-node `respondToEvent` workflow first, then
93+
* stores the trigger as `kind: "workflow"` pointing at that workflow.
94+
*/
95+
deployTextTriggerWorkflow: (
96+
runtime: IAgentRuntime,
97+
draft: TextTriggerWorkflowDraft,
98+
ownerId: string,
99+
) => Promise<DeployedTriggerWorkflow | null>;
85100
DISABLED_TRIGGER_INTERVAL_MS: number;
86101
TRIGGER_TASK_NAME: string;
87102
TRIGGER_TASK_TAGS: string[];
@@ -179,6 +194,7 @@ export async function handleTriggerRoutes(
179194
buildTriggerConfig,
180195
buildTriggerMetadata,
181196
normalizeTriggerDraft,
197+
deployTextTriggerWorkflow,
182198
DISABLED_TRIGGER_INTERVAL_MS,
183199
TRIGGER_TASK_NAME,
184200
TRIGGER_TASK_TAGS,
@@ -250,15 +266,57 @@ export async function handleTriggerRoutes(
250266
error(res, kindParsed.error, 400);
251267
return true;
252268
}
253-
const kind: TriggerKind | undefined = kindParsed?.ok
269+
const requestedKind: TriggerKind | undefined = kindParsed?.ok
254270
? kindParsed.kind
255271
: undefined;
256-
const workflowId = parseNonEmptyString(body.workflowId);
257-
const workflowName = parseNonEmptyString(body.workflowName);
258-
if (kind === "workflow" && !workflowId) {
272+
let workflowId = parseNonEmptyString(body.workflowId);
273+
let workflowName = parseNonEmptyString(body.workflowName);
274+
if (requestedKind === "workflow" && !workflowId) {
259275
error(res, "workflowId is required when kind is 'workflow'", 400);
260276
return true;
261277
}
278+
279+
// Phase 2E: when the client submits `kind: "text"` or omits `kind`,
280+
// materialize a single-node `respondToEvent` workflow up front so the
281+
// persisted trigger is always `kind: "workflow"`.
282+
if (requestedKind !== "workflow") {
283+
const rawDisplayName =
284+
typeof body.displayName === "string" && trim(body.displayName)
285+
? trim(body.displayName)
286+
: "New Trigger";
287+
const rawInstructions =
288+
typeof body.instructions === "string" ? trim(body.instructions) : "";
289+
if (!rawInstructions) {
290+
error(res, "instructions is required", 400);
291+
return true;
292+
}
293+
const wakeModeForWorkflow: TriggerWakeMode =
294+
typeof body.wakeMode === "string" &&
295+
body.wakeMode === "next_autonomy_cycle"
296+
? "next_autonomy_cycle"
297+
: "inject_now";
298+
const deployed = await deployTextTriggerWorkflow(
299+
runtime,
300+
{
301+
displayName: rawDisplayName,
302+
instructions: rawInstructions,
303+
wakeMode: wakeModeForWorkflow,
304+
},
305+
creator,
306+
);
307+
if (!deployed) {
308+
error(
309+
res,
310+
"Workflow plugin is not loaded; cannot create text triggers.",
311+
503,
312+
);
313+
return true;
314+
}
315+
workflowId = deployed.id;
316+
workflowName = deployed.name;
317+
}
318+
319+
const kind: TriggerKind = "workflow";
262320
const inputDraft: TriggerDraftInput = {
263321
displayName:
264322
typeof body.displayName === "string" ? body.displayName : undefined,

packages/agent/src/runtime/eliza.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3750,9 +3750,9 @@ export async function startEliza(
37503750
logger.info("[eliza] AutonomyService skipped — ENABLE_AUTONOMY=false");
37513751
}
37523752

3753-
// Enable the autonomy loop so trigger/heartbeat instructions are
3754-
// actually processed. Without this, memories created by
3755-
// dispatchInstruction() sit in the DB and are never acted on.
3753+
// Enable the autonomy loop so memories injected into the autonomy
3754+
// room (e.g. by workflow nodes that post into autonomy) are actually
3755+
// processed by the agent's autonomous reasoning.
37563756
if (autonomyEnabled) {
37573757
const autonomySvc = getAutonomyService(runtime);
37583758
if (autonomySvc) {

packages/agent/src/triggers/runtime.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ import type {
1414
TriggerTaskMetadata,
1515
} from "./types.js";
1616

17-
// Phase 2E: triggers always dispatch through the workflow service.
18-
// `dispatchInstruction` (autonomy-room memory injection) and
19-
// `isAutonomyServiceAvailable` are removed; `kind: "text"` no longer
20-
// reaches dispatch — the boot migration converts it on the way in, and
21-
// any stale on-disk record we encounter is logged + skipped.
17+
// Phase 2E: triggers always dispatch through the workflow service. The
18+
// previous autonomy-room memory injection path has been removed; every
19+
// persisted trigger is `kind: "workflow"`. Any stale `kind: "text"`
20+
// record we encounter at dispatch time is logged and skipped — the boot
21+
// migration rewrites it on the next cycle.
2222

2323
export const TRIGGER_TASK_NAME = "TRIGGER_DISPATCH" as const;
2424
export const TRIGGER_TASK_TAGS = ["queue", "repeat", "trigger"] as const;

packages/agent/src/triggers/scheduling.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,10 @@ export function buildTriggerDedupeKey(parts: {
338338
kind?: TriggerKind;
339339
workflowId?: string;
340340
}): string {
341-
const effectiveKind: TriggerKind = parts.kind ?? "text";
341+
// Phase 2E: every persisted trigger is `kind: "workflow"`. Stale
342+
// `kind: "text"` records are tolerated only on read (legacy hash) until
343+
// the boot migration rewrites them.
344+
const effectiveKind: TriggerKind = parts.kind ?? "workflow";
342345
const normalizedParts = [
343346
parts.triggerType,
344347
normalizeText(parts.instructions).toLowerCase(),
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { IAgentRuntime } from "@elizaos/core";
2+
import { describe, expect, it, vi } from "vitest";
3+
import {
4+
deployTextTriggerWorkflow,
5+
getWorkflowService,
6+
} from "./text-to-workflow.js";
7+
8+
interface DeployCall {
9+
workflow: {
10+
name: string;
11+
nodes: Array<{
12+
type: string;
13+
parameters: Record<string, unknown>;
14+
}>;
15+
connections: Record<string, unknown>;
16+
active?: boolean;
17+
};
18+
userId: string;
19+
}
20+
21+
function makeRuntimeWithService(
22+
deploy: (
23+
workflow: DeployCall["workflow"],
24+
userId: string,
25+
) => Promise<{ id?: string; name?: string }>,
26+
): { runtime: IAgentRuntime; calls: DeployCall[] } {
27+
const calls: DeployCall[] = [];
28+
const service = {
29+
deployWorkflow: async (
30+
workflow: DeployCall["workflow"],
31+
userId: string,
32+
) => {
33+
calls.push({ workflow, userId });
34+
return deploy(workflow, userId);
35+
},
36+
};
37+
const runtime = {
38+
getService: (type: string): unknown => (type === "workflow" ? service : null),
39+
} as unknown as IAgentRuntime;
40+
return { runtime, calls };
41+
}
42+
43+
describe("getWorkflowService", () => {
44+
it("returns null when the workflow service is not registered", () => {
45+
const runtime = {
46+
getService: () => null,
47+
} as unknown as IAgentRuntime;
48+
expect(getWorkflowService(runtime)).toBeNull();
49+
});
50+
51+
it("rejects services without a deployWorkflow method", () => {
52+
const runtime = {
53+
getService: () => ({ somethingElse: () => null }),
54+
} as unknown as IAgentRuntime;
55+
expect(getWorkflowService(runtime)).toBeNull();
56+
});
57+
});
58+
59+
describe("deployTextTriggerWorkflow", () => {
60+
it("returns null when the workflow service is missing", async () => {
61+
const runtime = {
62+
getService: () => null,
63+
} as unknown as IAgentRuntime;
64+
const result = await deployTextTriggerWorkflow(
65+
runtime,
66+
{
67+
displayName: "Daily PR sweep",
68+
instructions: "Review open PRs and summarize.",
69+
wakeMode: "inject_now",
70+
},
71+
"creator-1",
72+
);
73+
expect(result).toBeNull();
74+
});
75+
76+
it("materializes a single-node respondToEvent workflow with the trigger fields", async () => {
77+
const { runtime, calls } = makeRuntimeWithService(async () => ({
78+
id: "wf-123",
79+
name: "Daily PR sweep (auto)",
80+
}));
81+
82+
const result = await deployTextTriggerWorkflow(
83+
runtime,
84+
{
85+
displayName: "Daily PR sweep",
86+
instructions: "Review open PRs and summarize.",
87+
wakeMode: "next_autonomy_cycle",
88+
},
89+
"creator-1",
90+
);
91+
92+
expect(result).toEqual({ id: "wf-123", name: "Daily PR sweep (auto)" });
93+
expect(calls).toHaveLength(1);
94+
const [call] = calls;
95+
expect(call.userId).toBe("creator-1");
96+
expect(call.workflow.name).toBe("Daily PR sweep (auto)");
97+
expect(call.workflow.nodes).toHaveLength(1);
98+
expect(call.workflow.nodes[0].type).toBe(
99+
"workflows-nodes-base.respondToEvent",
100+
);
101+
expect(call.workflow.nodes[0].parameters).toEqual({
102+
instructions: "Review open PRs and summarize.",
103+
displayName: "Daily PR sweep",
104+
wakeMode: "next_autonomy_cycle",
105+
});
106+
expect(call.workflow.connections).toEqual({});
107+
});
108+
109+
it("returns null when the workflow service did not return an id", async () => {
110+
const { runtime } = makeRuntimeWithService(async () => ({
111+
id: "",
112+
name: "",
113+
}));
114+
const result = await deployTextTriggerWorkflow(
115+
runtime,
116+
{
117+
displayName: "no creds workflow",
118+
instructions: "Do the thing.",
119+
wakeMode: "inject_now",
120+
},
121+
"creator-1",
122+
);
123+
expect(result).toBeNull();
124+
});
125+
126+
it("falls back to the synthesized workflow name when the service returns no name", async () => {
127+
const deploy = vi.fn(async () => ({ id: "wf-1" }));
128+
const runtime = {
129+
getService: (type: string): unknown =>
130+
type === "workflow" ? { deployWorkflow: deploy } : null,
131+
} as unknown as IAgentRuntime;
132+
const result = await deployTextTriggerWorkflow(
133+
runtime,
134+
{
135+
displayName: "Greet",
136+
instructions: "Say hi.",
137+
wakeMode: "inject_now",
138+
},
139+
"creator-1",
140+
);
141+
expect(result).toEqual({ id: "wf-1", name: "Greet (auto)" });
142+
expect(deploy).toHaveBeenCalledTimes(1);
143+
});
144+
});

0 commit comments

Comments
 (0)