@trevonistrevon/pi-loop 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/dist/api.d.ts +2 -1
- package/dist/index.js +14 -0
- package/dist/loop-reducer.d.ts +24 -1
- package/dist/loop-reducer.js +31 -0
- package/dist/notification-reducer.d.ts +4 -1
- package/dist/runtime/notification-runtime.d.ts +4 -1
- package/dist/runtime/notification-runtime.js +27 -1
- package/dist/runtime/session-runtime.js +14 -3
- package/dist/runtime/task-backlog-runtime.d.ts +1 -1
- package/dist/runtime/task-backlog-runtime.js +1 -1
- package/dist/runtime/task-events.d.ts +2 -1
- package/dist/runtime/task-events.js +1 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +62 -1
- package/dist/store.d.ts +10 -1
- package/dist/store.js +51 -0
- package/dist/task-reducer.d.ts +2 -1
- package/dist/task-reducer.js +1 -0
- package/dist/task-store.d.ts +2 -2
- package/dist/task-store.js +2 -2
- package/dist/task-types.d.ts +6 -0
- package/dist/tools/loop-tools.d.ts +17 -1
- package/dist/tools/loop-tools.js +331 -26
- package/dist/tools/monitor-tools.js +43 -9
- package/dist/tools/native-task-tools.js +56 -11
- package/dist/tools/tool-result.d.ts +12 -2
- package/dist/tools/tool-result.js +7 -3
- package/dist/types.d.ts +35 -0
- package/dist/ui/tool-renderer.d.ts +7 -0
- package/dist/ui/tool-renderer.js +34 -0
- package/dist/ui/widget.js +4 -4
- package/dist/workflow-reducer.d.ts +18 -0
- package/dist/workflow-reducer.js +82 -0
- package/docs/USAGE_GUIDE.md +41 -0
- package/package.json +3 -3
- package/src/api.ts +9 -1
- package/src/index.ts +14 -0
- package/src/loop-reducer.ts +53 -1
- package/src/notification-reducer.ts +4 -1
- package/src/runtime/notification-runtime.ts +34 -2
- package/src/runtime/session-runtime.ts +15 -3
- package/src/runtime/task-backlog-runtime.ts +1 -1
- package/src/runtime/task-events.ts +3 -1
- package/src/runtime/task-rpc.ts +66 -0
- package/src/store.ts +51 -2
- package/src/task-reducer.ts +3 -1
- package/src/task-store.ts +3 -3
- package/src/task-types.ts +7 -0
- package/src/tools/loop-tools.ts +376 -20
- package/src/tools/monitor-tools.ts +44 -7
- package/src/tools/native-task-tools.ts +56 -8
- package/src/tools/tool-result.ts +18 -2
- package/src/types.ts +41 -0
- package/src/ui/tool-renderer.ts +45 -0
- package/src/ui/widget.ts +4 -4
- package/src/workflow-reducer.ts +107 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { createTask, deleteTask, updateTask, } from "../runtime/task-mutations.js";
|
|
3
|
-
import {
|
|
3
|
+
import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
|
|
4
|
+
import { displayRows, textResult } from "./tool-result.js";
|
|
4
5
|
function backlogSuffix(backlog) {
|
|
5
6
|
return backlog.created && backlog.entry
|
|
6
7
|
? `\nBacklog worker loop #${backlog.entry.id} created`
|
|
@@ -12,6 +13,8 @@ export function registerNativeTaskTools(options) {
|
|
|
12
13
|
pi.registerTool({
|
|
13
14
|
name: "TaskCreate",
|
|
14
15
|
label: "TaskCreate",
|
|
16
|
+
renderCall: renderToolCall("Task", (args) => `create · ${String(toolArg(args, "subject") ?? "task").slice(0, 56)}`),
|
|
17
|
+
renderResult: renderToolResult,
|
|
15
18
|
description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks or turn a broad user goal into a concrete backlog.
|
|
16
19
|
|
|
17
20
|
Fields:
|
|
@@ -38,18 +41,32 @@ Fields:
|
|
|
38
41
|
subject: params.subject,
|
|
39
42
|
description: params.description,
|
|
40
43
|
});
|
|
41
|
-
return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}
|
|
44
|
+
return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`, {
|
|
45
|
+
kind: "task",
|
|
46
|
+
action: "create",
|
|
47
|
+
tone: "success",
|
|
48
|
+
summary: `Task #${entry.id} pending · ${entry.subject.slice(0, 56)}`,
|
|
49
|
+
expanded: [
|
|
50
|
+
`Description: ${entry.description}`,
|
|
51
|
+
backlog.created && backlog.entry ? `Backlog worker: loop #${backlog.entry.id} created` : "Backlog worker: unchanged",
|
|
52
|
+
],
|
|
53
|
+
});
|
|
42
54
|
},
|
|
43
55
|
});
|
|
44
56
|
pi.registerTool({
|
|
45
57
|
name: "TaskList",
|
|
46
58
|
label: "TaskList",
|
|
59
|
+
renderCall: renderToolCall("Task", () => "status"),
|
|
60
|
+
renderResult: renderToolResult,
|
|
47
61
|
description: "List all tasks with status. Use to check progress and find available work.",
|
|
48
62
|
parameters: Type.Object({}),
|
|
49
63
|
execute() {
|
|
50
64
|
const tasks = taskStore.list();
|
|
51
|
-
if (tasks.length === 0)
|
|
52
|
-
return Promise.resolve(textResult("No tasks."
|
|
65
|
+
if (tasks.length === 0) {
|
|
66
|
+
return Promise.resolve(textResult("No tasks.", {
|
|
67
|
+
kind: "task", action: "list", tone: "info", summary: "No tasks", expanded: ["Use TaskCreate for work that spans turns."],
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
53
70
|
const lines = [];
|
|
54
71
|
const statuses = {
|
|
55
72
|
pending: 0,
|
|
@@ -62,12 +79,20 @@ Fields:
|
|
|
62
79
|
lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
|
|
63
80
|
}
|
|
64
81
|
lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
|
|
65
|
-
return Promise.resolve(textResult(lines.join("\n")
|
|
82
|
+
return Promise.resolve(textResult(lines.join("\n"), {
|
|
83
|
+
kind: "task",
|
|
84
|
+
action: "list",
|
|
85
|
+
tone: "info",
|
|
86
|
+
summary: `${tasks.length} task${tasks.length === 1 ? "" : "s"} · ${statuses.pending} pending · ${statuses.in_progress} active`,
|
|
87
|
+
expanded: displayRows(lines.slice(1)),
|
|
88
|
+
}));
|
|
66
89
|
},
|
|
67
90
|
});
|
|
68
91
|
pi.registerTool({
|
|
69
92
|
name: "TaskUpdate",
|
|
70
93
|
label: "TaskUpdate",
|
|
94
|
+
renderCall: renderToolCall("Task", (args) => `update · #${String(toolArg(args, "id") ?? "?")}`),
|
|
95
|
+
renderResult: renderToolResult,
|
|
71
96
|
description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
|
|
72
97
|
|
|
73
98
|
Statuses: pending → in_progress → completed
|
|
@@ -91,24 +116,44 @@ Parameters: id (required), status, subject, description`,
|
|
|
91
116
|
subject,
|
|
92
117
|
description,
|
|
93
118
|
});
|
|
94
|
-
if (!result)
|
|
95
|
-
return textResult(`Task #${id} not found
|
|
119
|
+
if (!result) {
|
|
120
|
+
return textResult(`Task #${id} not found`, {
|
|
121
|
+
kind: "task", action: "update", tone: "error", summary: `Task #${id} not found`, expanded: ["Use TaskList to find valid task IDs."],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
96
124
|
const statusMsg = status ? ` → ${status}` : "";
|
|
97
|
-
return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}
|
|
125
|
+
return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`, {
|
|
126
|
+
kind: "task",
|
|
127
|
+
action: "update",
|
|
128
|
+
tone: "success",
|
|
129
|
+
summary: `Task #${id}${status ? ` → ${status}` : " updated"}`,
|
|
130
|
+
expanded: [
|
|
131
|
+
`Subject: ${result.entry.subject}`,
|
|
132
|
+
`Status: ${result.entry.status}`,
|
|
133
|
+
result.backlog.created && result.backlog.entry ? `Backlog worker: loop #${result.backlog.entry.id} created` : "Backlog worker: unchanged",
|
|
134
|
+
],
|
|
135
|
+
});
|
|
98
136
|
},
|
|
99
137
|
});
|
|
100
138
|
pi.registerTool({
|
|
101
139
|
name: "TaskDelete",
|
|
102
140
|
label: "TaskDelete",
|
|
141
|
+
renderCall: renderToolCall("Task", (args) => `delete · #${String(toolArg(args, "id") ?? "?")}`),
|
|
142
|
+
renderResult: renderToolResult,
|
|
103
143
|
description: "Delete a task by ID. Use for cleaning up completed or irrelevant tasks.",
|
|
104
144
|
parameters: Type.Object({
|
|
105
145
|
id: Type.String({ description: "Task ID to delete" }),
|
|
106
146
|
}),
|
|
107
147
|
async execute(_toolCallId, params) {
|
|
108
148
|
const result = await deleteTask(mutationCtx, params.id);
|
|
109
|
-
if (!result)
|
|
110
|
-
return textResult(`Task #${params.id} not found
|
|
111
|
-
|
|
149
|
+
if (!result) {
|
|
150
|
+
return textResult(`Task #${params.id} not found`, {
|
|
151
|
+
kind: "task", action: "delete", tone: "error", summary: `Task #${params.id} not found`, expanded: ["Use TaskList to find valid task IDs."],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return textResult(`Task #${params.id} deleted`, {
|
|
155
|
+
kind: "task", action: "delete", tone: "success", summary: `Task #${params.id} deleted`, expanded: [],
|
|
156
|
+
});
|
|
112
157
|
},
|
|
113
158
|
});
|
|
114
159
|
}
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/** Plain-text tool result in the shape pi's registerTool expects. */
|
|
2
|
-
export
|
|
2
|
+
export type ToolDisplayKind = "loop" | "workflow" | "task" | "monitor";
|
|
3
|
+
export type ToolDisplayTone = "success" | "warning" | "error" | "info";
|
|
4
|
+
export interface ToolDisplayDetails {
|
|
5
|
+
kind: ToolDisplayKind;
|
|
6
|
+
action: string;
|
|
7
|
+
tone: ToolDisplayTone;
|
|
8
|
+
summary: string;
|
|
9
|
+
expanded?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function displayRows(rows: string[], limit?: number): string[];
|
|
12
|
+
export declare function textResult(msg: string, details?: ToolDisplayDetails): {
|
|
3
13
|
content: {
|
|
4
14
|
type: "text";
|
|
5
15
|
text: string;
|
|
6
16
|
}[];
|
|
7
|
-
details: undefined;
|
|
17
|
+
details: ToolDisplayDetails | undefined;
|
|
8
18
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export function displayRows(rows, limit = 8) {
|
|
2
|
+
if (rows.length <= limit)
|
|
3
|
+
return rows;
|
|
4
|
+
return [...rows.slice(0, limit), `… ${rows.length - limit} more`];
|
|
5
|
+
}
|
|
6
|
+
export function textResult(msg, details) {
|
|
7
|
+
return { content: [{ type: "text", text: msg }], details };
|
|
4
8
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -40,6 +40,40 @@ export interface DynamicLoopState {
|
|
|
40
40
|
awaitingUpdate?: boolean;
|
|
41
41
|
lastUpdatedAt?: number;
|
|
42
42
|
}
|
|
43
|
+
export type WorkflowTerminalStatus = "completed" | "paused";
|
|
44
|
+
export interface WorkflowTaskDefinition {
|
|
45
|
+
subject: string;
|
|
46
|
+
description: string;
|
|
47
|
+
}
|
|
48
|
+
export interface WorkflowStateDefinition {
|
|
49
|
+
prompt: string;
|
|
50
|
+
task?: WorkflowTaskDefinition;
|
|
51
|
+
on?: Record<string, string>;
|
|
52
|
+
terminal?: WorkflowTerminalStatus;
|
|
53
|
+
maxAttempts?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface WorkflowDefinition {
|
|
56
|
+
version: 1;
|
|
57
|
+
initialState: string;
|
|
58
|
+
states: Record<string, WorkflowStateDefinition>;
|
|
59
|
+
}
|
|
60
|
+
export interface WorkflowTransitionRecord {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
outcome: string;
|
|
64
|
+
evidence?: string;
|
|
65
|
+
at: number;
|
|
66
|
+
sequence: number;
|
|
67
|
+
}
|
|
68
|
+
export interface WorkflowRunState {
|
|
69
|
+
definition: WorkflowDefinition;
|
|
70
|
+
currentState: string;
|
|
71
|
+
transitionSeq: number;
|
|
72
|
+
stateEnteredAt: number;
|
|
73
|
+
attemptsByState: Record<string, number>;
|
|
74
|
+
activeTaskId?: string;
|
|
75
|
+
lastTransition?: WorkflowTransitionRecord;
|
|
76
|
+
}
|
|
43
77
|
export interface LoopEntry {
|
|
44
78
|
id: string;
|
|
45
79
|
prompt: string;
|
|
@@ -55,6 +89,7 @@ export interface LoopEntry {
|
|
|
55
89
|
maxFires?: number;
|
|
56
90
|
fireCount?: number;
|
|
57
91
|
dynamic?: DynamicLoopState;
|
|
92
|
+
workflow?: WorkflowRunState;
|
|
58
93
|
}
|
|
59
94
|
export interface LoopStoreData {
|
|
60
95
|
nextId: number;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
type ToolCallArgs = object;
|
|
4
|
+
export declare function renderToolCall(label: string, summarize: (args: ToolCallArgs) => string): (args: ToolCallArgs, theme: Theme) => Text;
|
|
5
|
+
export declare function toolArg(args: ToolCallArgs, name: string): unknown;
|
|
6
|
+
export declare function renderToolResult(result: AgentToolResult<unknown>, { expanded, isPartial }: ToolRenderResultOptions, theme: Theme): Text;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
export function renderToolCall(label, summarize) {
|
|
3
|
+
return (args, theme) => {
|
|
4
|
+
const summary = summarize(args);
|
|
5
|
+
const text = theme.fg("toolTitle", theme.bold(`${label} `)) + theme.fg("muted", summary);
|
|
6
|
+
return new Text(text, 0, 0);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function toolArg(args, name) {
|
|
10
|
+
return args[name];
|
|
11
|
+
}
|
|
12
|
+
export function renderToolResult(result, { expanded, isPartial }, theme) {
|
|
13
|
+
if (isPartial)
|
|
14
|
+
return new Text(theme.fg("warning", "Working…"), 0, 0);
|
|
15
|
+
const details = result.details;
|
|
16
|
+
if (!details) {
|
|
17
|
+
const content = result.content[0];
|
|
18
|
+
return new Text(content?.type === "text" ? content.text : "No result", 0, 0);
|
|
19
|
+
}
|
|
20
|
+
const color = details.tone === "success"
|
|
21
|
+
? "success"
|
|
22
|
+
: details.tone === "warning"
|
|
23
|
+
? "warning"
|
|
24
|
+
: details.tone === "error"
|
|
25
|
+
? "error"
|
|
26
|
+
: "muted";
|
|
27
|
+
const icon = details.tone === "success" ? "✓" : details.tone === "error" ? "✕" : details.tone === "warning" ? "!" : "•";
|
|
28
|
+
let text = theme.fg(color, `${icon} ${details.summary}`);
|
|
29
|
+
if (expanded && details.expanded?.length) {
|
|
30
|
+
for (const line of details.expanded)
|
|
31
|
+
text += `\n${theme.fg("dim", line)}`;
|
|
32
|
+
}
|
|
33
|
+
return new Text(text, 0, 0);
|
|
34
|
+
}
|
package/dist/ui/widget.js
CHANGED
|
@@ -23,18 +23,18 @@ export class LoopWidget {
|
|
|
23
23
|
}
|
|
24
24
|
computeStatus() {
|
|
25
25
|
const loops = this.store.list().filter(isStatusVisibleLoop);
|
|
26
|
-
const monitors = this.monitorManager.list();
|
|
26
|
+
const monitors = this.monitorManager.list().filter((monitor) => monitor.status === "running");
|
|
27
27
|
const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
|
|
28
28
|
if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
|
|
29
29
|
return undefined;
|
|
30
30
|
}
|
|
31
31
|
const parts = [];
|
|
32
32
|
if (loops.length > 0)
|
|
33
|
-
parts.push(formatCount(loops.length, "loop"));
|
|
33
|
+
parts.push(`↻ ${formatCount(loops.length, "loop")}`);
|
|
34
34
|
if (monitors.length > 0)
|
|
35
|
-
parts.push(formatCount(monitors.length, "monitor"));
|
|
35
|
+
parts.push(`▶ ${formatCount(monitors.length, "monitor")}`);
|
|
36
36
|
if (taskSummary.count > 0)
|
|
37
|
-
parts.push(formatCount(taskSummary.count, "task"));
|
|
37
|
+
parts.push(`□ ${formatCount(taskSummary.count, "task")}`);
|
|
38
38
|
let line = parts.join(" · ");
|
|
39
39
|
if (taskSummary.focusText)
|
|
40
40
|
line += ` | ${taskSummary.focusText}`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { WorkflowDefinition, WorkflowRunState, WorkflowTerminalStatus } from "./types.js";
|
|
2
|
+
export type { WorkflowDefinition, WorkflowRunState, WorkflowStateDefinition, WorkflowTerminalStatus, WorkflowTransitionRecord, } from "./types.js";
|
|
3
|
+
export interface WorkflowTransitionInput {
|
|
4
|
+
outcome: string;
|
|
5
|
+
evidence?: string;
|
|
6
|
+
activeTaskId?: string;
|
|
7
|
+
}
|
|
8
|
+
export type WorkflowTransitionResult = {
|
|
9
|
+
applied: true;
|
|
10
|
+
run: WorkflowRunState;
|
|
11
|
+
terminal?: WorkflowTerminalStatus;
|
|
12
|
+
} | {
|
|
13
|
+
applied: false;
|
|
14
|
+
error: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function validateWorkflowDefinition(definition: WorkflowDefinition): string | undefined;
|
|
17
|
+
export declare function createWorkflowRun(definition: WorkflowDefinition, at: number): WorkflowRunState;
|
|
18
|
+
export declare function transitionWorkflowRun(run: WorkflowRunState, input: WorkflowTransitionInput, at: number): WorkflowTransitionResult;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function validateWorkflowDefinition(definition) {
|
|
2
|
+
if (!definition || definition.version !== 1)
|
|
3
|
+
return "Workflow version must be 1";
|
|
4
|
+
if (!definition.states || typeof definition.states !== "object")
|
|
5
|
+
return "Workflow states must be an object";
|
|
6
|
+
if (!definition.initialState || !definition.states[definition.initialState]) {
|
|
7
|
+
return `Initial state "${definition.initialState}" is not defined`;
|
|
8
|
+
}
|
|
9
|
+
for (const [stateId, state] of Object.entries(definition.states)) {
|
|
10
|
+
if (!stateId)
|
|
11
|
+
return "Workflow state IDs must be non-empty";
|
|
12
|
+
if (!state || typeof state !== "object")
|
|
13
|
+
return `State "${stateId}" must be an object`;
|
|
14
|
+
if (typeof state.prompt !== "string")
|
|
15
|
+
return `State "${stateId}" requires a prompt`;
|
|
16
|
+
if (!state.prompt.trim())
|
|
17
|
+
return `State "${stateId}" requires a prompt`;
|
|
18
|
+
if (state.on !== undefined && (typeof state.on !== "object" || Array.isArray(state.on))) {
|
|
19
|
+
return `State "${stateId}" transitions must be an object`;
|
|
20
|
+
}
|
|
21
|
+
if (state.terminal && state.on && Object.keys(state.on).length > 0) {
|
|
22
|
+
return `Terminal state "${stateId}" cannot declare transitions`;
|
|
23
|
+
}
|
|
24
|
+
if (state.maxAttempts !== undefined && (!Number.isInteger(state.maxAttempts) || state.maxAttempts < 1)) {
|
|
25
|
+
return `State "${stateId}" maxAttempts must be a positive integer`;
|
|
26
|
+
}
|
|
27
|
+
for (const [outcome, target] of Object.entries(state.on ?? {})) {
|
|
28
|
+
if (!outcome)
|
|
29
|
+
return `State "${stateId}" has an empty outcome name`;
|
|
30
|
+
if (typeof target !== "string")
|
|
31
|
+
return `Transition "${stateId}.${outcome}" target must be a state ID`;
|
|
32
|
+
if (!definition.states[target])
|
|
33
|
+
return `Transition "${stateId}.${outcome}" targets unknown state "${target}"`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function createWorkflowRun(definition, at) {
|
|
39
|
+
return {
|
|
40
|
+
definition,
|
|
41
|
+
currentState: definition.initialState,
|
|
42
|
+
transitionSeq: 0,
|
|
43
|
+
stateEnteredAt: at,
|
|
44
|
+
attemptsByState: { [definition.initialState]: 1 },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function transitionWorkflowRun(run, input, at) {
|
|
48
|
+
const current = run.definition.states[run.currentState];
|
|
49
|
+
if (!current)
|
|
50
|
+
return { applied: false, error: `Current state "${run.currentState}" is not defined` };
|
|
51
|
+
if (current.terminal)
|
|
52
|
+
return { applied: false, error: `Workflow is already ${current.terminal}` };
|
|
53
|
+
const target = current.on?.[input.outcome];
|
|
54
|
+
if (!target)
|
|
55
|
+
return { applied: false, error: `Outcome "${input.outcome}" is not allowed from state "${run.currentState}"` };
|
|
56
|
+
const targetState = run.definition.states[target];
|
|
57
|
+
if (!targetState)
|
|
58
|
+
return { applied: false, error: `Transition target "${target}" is not defined` };
|
|
59
|
+
const nextAttempt = (run.attemptsByState[target] ?? 0) + 1;
|
|
60
|
+
if (targetState.maxAttempts !== undefined && nextAttempt > targetState.maxAttempts) {
|
|
61
|
+
return { applied: false, error: `State "${target}" has exhausted its ${targetState.maxAttempts} attempt limit` };
|
|
62
|
+
}
|
|
63
|
+
const sequence = run.transitionSeq + 1;
|
|
64
|
+
const lastTransition = {
|
|
65
|
+
from: run.currentState,
|
|
66
|
+
to: target,
|
|
67
|
+
outcome: input.outcome,
|
|
68
|
+
evidence: input.evidence,
|
|
69
|
+
at,
|
|
70
|
+
sequence,
|
|
71
|
+
};
|
|
72
|
+
const nextRun = {
|
|
73
|
+
...run,
|
|
74
|
+
currentState: target,
|
|
75
|
+
transitionSeq: sequence,
|
|
76
|
+
stateEnteredAt: at,
|
|
77
|
+
attemptsByState: { ...run.attemptsByState, [target]: nextAttempt },
|
|
78
|
+
activeTaskId: input.activeTaskId,
|
|
79
|
+
lastTransition,
|
|
80
|
+
};
|
|
81
|
+
return { applied: true, run: nextRun, terminal: targetState.terminal };
|
|
82
|
+
}
|
package/docs/USAGE_GUIDE.md
CHANGED
|
@@ -60,6 +60,47 @@ LoopUpdate id="1" status="completed"
|
|
|
60
60
|
|
|
61
61
|
Paused dynamic loops can be resumed from the `/loop` menu. If an in-memory wake is lost during a restart or session switch, persisted dynamic state recovers it.
|
|
62
62
|
|
|
63
|
+
### Opt-in workflow loops
|
|
64
|
+
|
|
65
|
+
Use a workflow only when work has stable named phases and outcomes. Ordinary reminders, polling, event hooks, and flat task backlogs should continue to use `LoopCreate` or the task tools.
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
WorkflowCreate goal="Fix the regression" definition='{
|
|
69
|
+
"version": 1,
|
|
70
|
+
"initialState": "investigate",
|
|
71
|
+
"states": {
|
|
72
|
+
"investigate": {
|
|
73
|
+
"prompt": "Find and verify the root cause.",
|
|
74
|
+
"task": { "subject": "Investigate regression", "description": "Find the root cause and reproduce it." },
|
|
75
|
+
"on": { "root_cause_found": "fix", "blocked": "blocked" }
|
|
76
|
+
},
|
|
77
|
+
"fix": {
|
|
78
|
+
"prompt": "Implement and validate the fix.",
|
|
79
|
+
"task": { "subject": "Implement fix", "description": "Make the smallest fix and run targeted validation." },
|
|
80
|
+
"on": { "tests_pass": "done", "regression_found": "investigate" },
|
|
81
|
+
"maxAttempts": 2
|
|
82
|
+
},
|
|
83
|
+
"done": { "prompt": "Report completion.", "terminal": "completed" },
|
|
84
|
+
"blocked": { "prompt": "Report the blocker.", "terminal": "paused" }
|
|
85
|
+
}
|
|
86
|
+
}'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Each wake presents the current state, state instructions, active task, and allowed outcomes. The agent finishes the state by selecting one declared outcome:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
WorkflowTransition id="1" outcome="root_cause_found" evidence="A null config reaches the parser."
|
|
93
|
+
WorkflowTransition id="1" outcome="tests_pass" evidence="Targeted and full test suites pass."
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`WorkflowTransition` validates the branch, records evidence, creates the next state's optional task, and queues the next wake. Reaching a `completed` terminal state deletes the workflow loop; reaching a `paused` terminal state preserves it in paused state. Task completion does not guess an outcome—the model selects one explicitly.
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
WorkflowList
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Lists only workflow loops, including their active state, active task, and valid outcomes.
|
|
103
|
+
|
|
63
104
|
### Inspecting and stopping loops
|
|
64
105
|
|
|
65
106
|
```text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevonistrevon/pi-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
|
|
5
5
|
"author": "trevonistrevon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"./package.json": "./package.json"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
35
|
-
"@earendil-works/pi-tui": "^0.
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.81.1",
|
|
35
|
+
"@earendil-works/pi-tui": "^0.81.1"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"typebox": "^1.1.34"
|
package/src/api.ts
CHANGED
|
@@ -33,4 +33,12 @@ export {
|
|
|
33
33
|
export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
|
|
34
34
|
export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
|
|
35
35
|
export { TaskStore } from "./task-store.js";
|
|
36
|
-
export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
|
|
36
|
+
export type { TaskEntry, TaskStatus, TaskStoreData, TaskWorkflowLink } from "./task-types.js";
|
|
37
|
+
export type {
|
|
38
|
+
WorkflowDefinition,
|
|
39
|
+
WorkflowRunState,
|
|
40
|
+
WorkflowStateDefinition,
|
|
41
|
+
WorkflowTaskDefinition,
|
|
42
|
+
WorkflowTerminalStatus,
|
|
43
|
+
WorkflowTransitionRecord,
|
|
44
|
+
} from "./types.js";
|
package/src/index.ts
CHANGED
|
@@ -128,6 +128,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
128
|
onDetectionSettled: () => {
|
|
129
129
|
tasksDetectionSettled = true;
|
|
130
130
|
},
|
|
131
|
+
isDetectionSettled: () => tasksDetectionSettled,
|
|
132
|
+
onNativeTaskCompleted: () => {
|
|
133
|
+
widget.update();
|
|
134
|
+
},
|
|
131
135
|
debug,
|
|
132
136
|
});
|
|
133
137
|
|
|
@@ -200,7 +204,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
204
|
timestamp: Date.now(),
|
|
201
205
|
readOnly: entry.readOnly,
|
|
202
206
|
recurring: false,
|
|
207
|
+
persistent: entry.recurring,
|
|
203
208
|
autoTask: true,
|
|
209
|
+
taskBacklog: entry.taskBacklog,
|
|
204
210
|
});
|
|
205
211
|
return true;
|
|
206
212
|
}
|
|
@@ -276,8 +282,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
276
282
|
timestamp: firedAt,
|
|
277
283
|
readOnly: firedEntry.readOnly,
|
|
278
284
|
recurring: firedEntry.recurring,
|
|
285
|
+
persistent: firedEntry.recurring,
|
|
279
286
|
autoTask: firedEntry.autoTask,
|
|
287
|
+
taskBacklog: firedEntry.taskBacklog,
|
|
280
288
|
dynamic: firedEntry.dynamic,
|
|
289
|
+
workflow: firedEntry.workflow,
|
|
281
290
|
});
|
|
282
291
|
}
|
|
283
292
|
|
|
@@ -345,6 +354,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
345
354
|
},
|
|
346
355
|
maybeBootstrapTaskLoop,
|
|
347
356
|
isTaskSystemReady: () => tasksAvailable || nativeTasksRegistered,
|
|
357
|
+
onDynamicLoopActivated: (entry) => {
|
|
358
|
+
onLoopFire(entry);
|
|
359
|
+
},
|
|
360
|
+
createWorkflowTask: (entry) => taskRuntime.createWorkflowTask(entry),
|
|
361
|
+
completeWorkflowTask: (taskId) => taskRuntime.completeWorkflowTask(taskId),
|
|
348
362
|
});
|
|
349
363
|
|
|
350
364
|
function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
|
package/src/loop-reducer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, LoopEntry, Trigger, WorkflowDefinition } from "./types.js";
|
|
2
|
+
import { createWorkflowRun, transitionWorkflowRun } from "./workflow-reducer.js";
|
|
2
3
|
|
|
3
4
|
export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
5
|
|
|
@@ -35,6 +36,7 @@ export type LoopReducerEvent =
|
|
|
35
36
|
readOnly?: boolean;
|
|
36
37
|
maxFires?: number;
|
|
37
38
|
dynamic?: Partial<DynamicLoopState>;
|
|
39
|
+
workflow?: WorkflowDefinition;
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
| {
|
|
@@ -73,6 +75,27 @@ export type LoopReducerEvent =
|
|
|
73
75
|
prompt?: string;
|
|
74
76
|
dynamic: Partial<DynamicLoopState>;
|
|
75
77
|
};
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
type: "LOOP_WORKFLOW_TRANSITION";
|
|
81
|
+
at: number;
|
|
82
|
+
source: ReducerSource;
|
|
83
|
+
entityType?: "loop";
|
|
84
|
+
entityId?: string;
|
|
85
|
+
payload: {
|
|
86
|
+
id: string;
|
|
87
|
+
outcome: string;
|
|
88
|
+
evidence?: string;
|
|
89
|
+
activeTaskId?: string;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
| {
|
|
93
|
+
type: "LOOP_WORKFLOW_TASK_SET";
|
|
94
|
+
at: number;
|
|
95
|
+
source: ReducerSource;
|
|
96
|
+
entityType?: "loop";
|
|
97
|
+
entityId?: string;
|
|
98
|
+
payload: { id: string; taskId?: string };
|
|
76
99
|
};
|
|
77
100
|
|
|
78
101
|
export type LoopReducerEffect =
|
|
@@ -131,6 +154,7 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
|
|
|
131
154
|
lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
|
|
132
155
|
}
|
|
133
156
|
: undefined,
|
|
157
|
+
workflow: event.payload.workflow ? createWorkflowRun(event.payload.workflow, event.at) : undefined,
|
|
134
158
|
};
|
|
135
159
|
next.loopsById[id] = loop;
|
|
136
160
|
return {
|
|
@@ -190,6 +214,34 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
|
|
|
190
214
|
loop.updatedAt = event.at;
|
|
191
215
|
}
|
|
192
216
|
|
|
217
|
+
if (event.type === "LOOP_WORKFLOW_TRANSITION") {
|
|
218
|
+
if (!loop.workflow) return { state, effects: [] };
|
|
219
|
+
const result = transitionWorkflowRun(loop.workflow, {
|
|
220
|
+
outcome: event.payload.outcome,
|
|
221
|
+
evidence: event.payload.evidence,
|
|
222
|
+
activeTaskId: event.payload.activeTaskId,
|
|
223
|
+
}, event.at);
|
|
224
|
+
if (!result.applied) return { state, effects: [] };
|
|
225
|
+
loop.workflow = result.run;
|
|
226
|
+
loop.dynamic = {
|
|
227
|
+
goal: loop.dynamic?.goal ?? loop.prompt,
|
|
228
|
+
state: result.run.currentState,
|
|
229
|
+
metrics: loop.dynamic?.metrics,
|
|
230
|
+
doneCriteria: loop.dynamic?.doneCriteria,
|
|
231
|
+
iteration: (loop.dynamic?.iteration ?? 0) + 1,
|
|
232
|
+
nextWakeAt: undefined,
|
|
233
|
+
awaitingUpdate: false,
|
|
234
|
+
lastUpdatedAt: event.at,
|
|
235
|
+
};
|
|
236
|
+
loop.updatedAt = event.at;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (event.type === "LOOP_WORKFLOW_TASK_SET") {
|
|
240
|
+
if (!loop.workflow) return { state, effects: [] };
|
|
241
|
+
loop.workflow = { ...loop.workflow, activeTaskId: event.payload.taskId };
|
|
242
|
+
loop.updatedAt = event.at;
|
|
243
|
+
}
|
|
244
|
+
|
|
193
245
|
next.loopsById[id] = loop;
|
|
194
246
|
return {
|
|
195
247
|
state: next,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DynamicLoopState } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, WorkflowRunState } from "./types.js";
|
|
2
2
|
|
|
3
3
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
4
4
|
|
|
@@ -9,9 +9,12 @@ export interface ReducerNotification {
|
|
|
9
9
|
timestamp: number;
|
|
10
10
|
trigger: unknown;
|
|
11
11
|
recurring?: boolean;
|
|
12
|
+
persistent?: boolean;
|
|
12
13
|
autoTask?: boolean;
|
|
14
|
+
taskBacklog?: boolean;
|
|
13
15
|
readOnly?: boolean;
|
|
14
16
|
dynamic?: DynamicLoopState;
|
|
17
|
+
workflow?: WorkflowRunState;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export interface NotificationReducerState {
|