openbot 0.3.6 → 0.4.2
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/README.md +15 -16
- package/dist/app/agent-ids.js +4 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/config.js +10 -19
- package/dist/app/server.js +208 -17
- package/dist/bus/services.js +34 -124
- package/dist/harness/agent-invoke-run.js +44 -0
- package/dist/harness/agent-turn.js +99 -0
- package/dist/harness/channel-participants.js +40 -0
- package/dist/harness/constants.js +2 -0
- package/dist/harness/context-meter.js +97 -0
- package/dist/harness/context.js +95 -47
- package/dist/harness/dispatch.js +144 -0
- package/dist/harness/dispatcher.js +45 -156
- package/dist/harness/history.js +177 -0
- package/dist/harness/index.js +109 -0
- package/dist/harness/orchestration.js +88 -0
- package/dist/harness/participants.js +22 -0
- package/dist/harness/run-harness.js +154 -0
- package/dist/harness/run.js +98 -0
- package/dist/harness/runtime-factory.js +0 -34
- package/dist/harness/runtime.js +57 -0
- package/dist/harness/todo-dispatch.js +51 -0
- package/dist/harness/todos.js +5 -0
- package/dist/harness/turn.js +79 -0
- package/dist/plugins/approval/index.js +120 -149
- package/dist/plugins/bash/index.js +195 -0
- package/dist/plugins/delegation/index.js +121 -32
- package/dist/plugins/memory/index.js +103 -14
- package/dist/plugins/memory/service.js +152 -0
- package/dist/plugins/openbot/context.js +125 -0
- package/dist/plugins/openbot/history.js +144 -0
- package/dist/plugins/openbot/index.js +71 -0
- package/dist/plugins/openbot/runtime.js +381 -0
- package/dist/plugins/openbot/system-prompt.js +25 -0
- package/dist/plugins/plugin-manager/index.js +189 -0
- package/dist/plugins/shell/index.js +2 -1
- package/dist/plugins/storage/files.js +67 -0
- package/dist/plugins/storage/index.js +750 -0
- package/dist/plugins/storage/service.js +1316 -0
- package/dist/plugins/storage-tools/index.js +2 -2
- package/dist/plugins/thread-namer/index.js +72 -0
- package/dist/plugins/thread-naming/generate-title.js +44 -0
- package/dist/plugins/thread-naming/index.js +103 -0
- package/dist/plugins/threads/index.js +114 -0
- package/dist/plugins/todo/index.js +24 -25
- package/dist/plugins/ui/index.js +109 -180
- package/dist/registry/plugins.js +3 -9
- package/dist/services/abort.js +43 -0
- package/dist/services/plugins/domain.js +1 -0
- package/dist/services/plugins/plugin-cache.js +9 -0
- package/dist/services/plugins/registry.js +112 -0
- package/dist/services/plugins/service.js +232 -0
- package/dist/services/plugins/types.js +1 -0
- package/dist/services/process.js +29 -0
- package/dist/services/storage.js +11 -10
- package/dist/services/thread-naming.js +81 -0
- package/docs/agents.md +15 -12
- package/docs/architecture.md +2 -2
- package/docs/plugins.md +29 -17
- package/docs/templates/AGENT.example.md +8 -14
- package/package.json +1 -2
- package/src/app/agent-ids.ts +5 -0
- package/src/app/cli.ts +1 -1
- package/src/app/config.ts +14 -31
- package/src/app/server.ts +243 -19
- package/src/app/types.ts +331 -187
- package/src/harness/index.ts +166 -0
- package/src/plugins/approval/index.ts +107 -188
- package/src/plugins/bash/index.ts +232 -0
- package/src/plugins/delegation/index.ts +139 -39
- package/src/plugins/memory/index.ts +112 -15
- package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
- package/src/plugins/openbot/context.ts +140 -0
- package/src/plugins/openbot/history.ts +158 -0
- package/src/plugins/openbot/index.ts +79 -0
- package/src/plugins/openbot/runtime.ts +478 -0
- package/src/plugins/openbot/system-prompt.ts +27 -0
- package/src/plugins/plugin-manager/index.ts +224 -0
- package/src/plugins/storage/files.ts +81 -0
- package/src/plugins/storage/index.ts +823 -0
- package/src/{services/storage.ts → plugins/storage/service.ts} +485 -105
- package/src/plugins/ui/index.ts +117 -221
- package/src/services/abort.ts +46 -0
- package/src/{bus/types.ts → services/plugins/domain.ts} +50 -8
- package/src/services/plugins/plugin-cache.ts +13 -0
- package/src/{registry/plugins.ts → services/plugins/registry.ts} +28 -28
- package/src/services/plugins/service.ts +318 -0
- package/src/{bus/plugin.ts → services/plugins/types.ts} +7 -3
- package/src/bus/services.ts +0 -954
- package/src/harness/context.ts +0 -365
- package/src/harness/dispatcher.ts +0 -379
- package/src/harness/mcp.ts +0 -78
- package/src/harness/runtime-factory.ts +0 -129
- package/src/harness/todo-advance.ts +0 -128
- package/src/plugins/ai-sdk/index.ts +0 -41
- package/src/plugins/ai-sdk/runtime.ts +0 -468
- package/src/plugins/ai-sdk/system-prompt.ts +0 -18
- package/src/plugins/mcp/index.ts +0 -128
- package/src/plugins/shell/index.ts +0 -123
- package/src/plugins/storage-tools/index.ts +0 -90
- package/src/plugins/todo/index.ts +0 -64
- package/src/services/plugins.ts +0 -133
- /package/src/{harness → services}/process.ts +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { melony } from 'melony';
|
|
2
|
+
import { OpenBotEvent, OpenBotState } from '../app/types.js';
|
|
3
|
+
import { ensureEventId } from '../app/utils.js';
|
|
4
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
5
|
+
import { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../app/agent-ids.js';
|
|
6
|
+
import { resolvePlugin } from '../services/plugins/registry.js';
|
|
7
|
+
import { ToolDefinition } from '../services/plugins/types.js';
|
|
8
|
+
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
9
|
+
import { loadConfig } from '../app/config.js';
|
|
10
|
+
import { getPublicBaseUrl } from '../plugins/storage/files.js';
|
|
11
|
+
|
|
12
|
+
export { STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID };
|
|
13
|
+
|
|
14
|
+
export interface RunAgentOptions {
|
|
15
|
+
runId: string;
|
|
16
|
+
agentId: string;
|
|
17
|
+
event: OpenBotEvent;
|
|
18
|
+
channelId: string;
|
|
19
|
+
threadId?: string;
|
|
20
|
+
persistEvents?: boolean;
|
|
21
|
+
/** Resolved public base URL for the server. */
|
|
22
|
+
publicBaseUrl?: string;
|
|
23
|
+
onEvent: (event: OpenBotEvent, state?: OpenBotState) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function emitEvent(
|
|
27
|
+
chunk: OpenBotEvent,
|
|
28
|
+
state: OpenBotState | undefined,
|
|
29
|
+
{
|
|
30
|
+
persistEvents,
|
|
31
|
+
channelId,
|
|
32
|
+
threadId,
|
|
33
|
+
onEvent,
|
|
34
|
+
parentAgentId,
|
|
35
|
+
parentToolCallId,
|
|
36
|
+
}: {
|
|
37
|
+
persistEvents: boolean;
|
|
38
|
+
channelId: string;
|
|
39
|
+
threadId?: string;
|
|
40
|
+
onEvent: RunAgentOptions['onEvent'];
|
|
41
|
+
parentAgentId?: string;
|
|
42
|
+
parentToolCallId?: string;
|
|
43
|
+
},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
ensureEventId(chunk);
|
|
46
|
+
|
|
47
|
+
// Enrich event with parent metadata if not already present
|
|
48
|
+
if (parentAgentId || parentToolCallId) {
|
|
49
|
+
chunk.meta = {
|
|
50
|
+
...chunk.meta,
|
|
51
|
+
parentAgentId: chunk.meta?.parentAgentId || parentAgentId,
|
|
52
|
+
parentToolCallId: chunk.meta?.parentToolCallId || parentToolCallId,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (persistEvents) {
|
|
57
|
+
await storageService.storeEvent({
|
|
58
|
+
channelId: state?.channelId || channelId,
|
|
59
|
+
threadId: state?.threadId || threadId,
|
|
60
|
+
event: chunk,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await onEvent(chunk, state);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs a single agent turn.
|
|
69
|
+
* Fire and forget.
|
|
70
|
+
*/
|
|
71
|
+
export async function runAgent(options: RunAgentOptions): Promise<void> {
|
|
72
|
+
const { runId, agentId, event, channelId, threadId, onEvent } = options;
|
|
73
|
+
const persistEvents = options.persistEvents !== false;
|
|
74
|
+
|
|
75
|
+
let publicBaseUrl = options.publicBaseUrl;
|
|
76
|
+
if (!publicBaseUrl) {
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const port = Number(config.port ?? process.env.PORT ?? 4132);
|
|
79
|
+
publicBaseUrl = getPublicBaseUrl(port, config.publicUrl);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parentAgentId = event.meta?.parentAgentId;
|
|
83
|
+
const parentToolCallId = event.meta?.parentToolCallId;
|
|
84
|
+
|
|
85
|
+
const agentDetails = await storageService.getAgentDetails({ agentId });
|
|
86
|
+
|
|
87
|
+
const state = await storageService.getOpenBotState({
|
|
88
|
+
runId,
|
|
89
|
+
agentId,
|
|
90
|
+
channelId,
|
|
91
|
+
threadId,
|
|
92
|
+
event,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Shared per-thread abort signal so a stop request cancels this run and any
|
|
96
|
+
// delegated sub-agent runs (which execute in the same channel/thread).
|
|
97
|
+
const runKey = abortKey(channelId, threadId);
|
|
98
|
+
const abortSignal = abortRegistry.acquire(runKey);
|
|
99
|
+
|
|
100
|
+
await emitEvent(
|
|
101
|
+
{
|
|
102
|
+
type: 'agent:run:start',
|
|
103
|
+
data: { runId, agentId, channelId, threadId },
|
|
104
|
+
} as OpenBotEvent,
|
|
105
|
+
state,
|
|
106
|
+
{ persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId },
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const pluginRefs = agentDetails.pluginRefs ?? [];
|
|
111
|
+
const tools: Record<string, ToolDefinition> = {};
|
|
112
|
+
|
|
113
|
+
for (const ref of pluginRefs) {
|
|
114
|
+
const plugin = await resolvePlugin(ref.id);
|
|
115
|
+
if (plugin?.toolDefinitions) {
|
|
116
|
+
Object.assign(tools, plugin.toolDefinitions);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const builder = melony<OpenBotState, OpenBotEvent>().initialState(state);
|
|
121
|
+
|
|
122
|
+
for (const ref of pluginRefs) {
|
|
123
|
+
const plugin = await resolvePlugin(ref.id);
|
|
124
|
+
if (!plugin) continue;
|
|
125
|
+
|
|
126
|
+
builder.use(
|
|
127
|
+
plugin.factory({
|
|
128
|
+
agentId,
|
|
129
|
+
agentDetails,
|
|
130
|
+
config: ref.config ?? {},
|
|
131
|
+
storage: storageService,
|
|
132
|
+
tools,
|
|
133
|
+
publicBaseUrl,
|
|
134
|
+
abortSignal,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const runtime = builder.build();
|
|
140
|
+
const generator = runtime.run(event, { runId, state });
|
|
141
|
+
|
|
142
|
+
for await (const outputEvent of generator) {
|
|
143
|
+
if (abortSignal.aborted) break;
|
|
144
|
+
await emitEvent(outputEvent, state, {
|
|
145
|
+
persistEvents,
|
|
146
|
+
channelId,
|
|
147
|
+
threadId,
|
|
148
|
+
onEvent,
|
|
149
|
+
parentAgentId,
|
|
150
|
+
parentToolCallId,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error(`[harness] Error running agent ${agentId}:`, error);
|
|
155
|
+
} finally {
|
|
156
|
+
abortRegistry.release(runKey);
|
|
157
|
+
await emitEvent(
|
|
158
|
+
{
|
|
159
|
+
type: 'agent:run:end',
|
|
160
|
+
data: { runId, agentId, channelId, threadId },
|
|
161
|
+
} as OpenBotEvent,
|
|
162
|
+
state,
|
|
163
|
+
{ persistEvents, channelId, threadId, onEvent, parentAgentId, parentToolCallId },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -1,228 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Plugin } from '../../
|
|
3
|
-
import { OpenBotEvent
|
|
4
|
-
import { storageService } from '../../services/storage.js';
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Plugin } from '../../services/plugins/types.js';
|
|
3
|
+
import { OpenBotEvent } from '../../app/types.js';
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* `approval` — gates protected tool calls behind a UI confirmation widget.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* plugins:
|
|
12
|
-
* - id: approval
|
|
13
|
-
* config:
|
|
14
|
-
* rules:
|
|
15
|
-
* - action: action:shell_exec
|
|
16
|
-
* message: The agent wants to run a terminal command.
|
|
17
|
-
* detailKeys: [command, cwd, shell, timeoutMs]
|
|
18
|
-
* ```
|
|
7
|
+
*
|
|
8
|
+
* This is a simplified version that intercepts specified actions (default: bash)
|
|
9
|
+
* and requires user approval before they are allowed to proceed.
|
|
19
10
|
*/
|
|
20
11
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
detailKeys?: string[];
|
|
25
|
-
hiddenKeys?: string[];
|
|
26
|
-
executeEvent?: string;
|
|
27
|
-
denyEvent?: string;
|
|
28
|
-
denyData?: Record<string, unknown>;
|
|
29
|
-
};
|
|
12
|
+
// In-memory tracking for pending approval IDs with TTL (shared across plugin instances)
|
|
13
|
+
const pendingApprovals = new Map<string, number>();
|
|
14
|
+
const TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
30
15
|
|
|
31
|
-
export const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
denyData: {
|
|
39
|
-
exitCode: null,
|
|
40
|
-
stdout: '',
|
|
41
|
-
stderr: 'Command execution was denied by the user.',
|
|
42
|
-
timedOut: false,
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
type PendingApproval = {
|
|
48
|
-
id: string;
|
|
49
|
-
action: string;
|
|
50
|
-
executeEvent: string;
|
|
51
|
-
denyEvent: string;
|
|
52
|
-
denyData: Record<string, unknown>;
|
|
53
|
-
payload: Record<string, unknown>;
|
|
54
|
-
meta?: Record<string, unknown>;
|
|
55
|
-
message: string;
|
|
56
|
-
createdAt: string;
|
|
57
|
-
status: 'pending' | 'approved' | 'denied';
|
|
58
|
-
};
|
|
16
|
+
export const approvalPlugin: Plugin = {
|
|
17
|
+
id: 'approval',
|
|
18
|
+
name: 'Approval',
|
|
19
|
+
description: 'Gate protected tool calls behind a UI confirmation widget.',
|
|
20
|
+
factory: ({ config, storage }) => (builder) => {
|
|
21
|
+
// Actions that require approval. Defaults to bash.
|
|
22
|
+
const actionsToApprove = (config.actions as string[]) || ['action:bash'];
|
|
59
23
|
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
24
|
+
for (const action of actionsToApprove) {
|
|
25
|
+
builder.intercept(action as OpenBotEvent['type'], (event, context) => {
|
|
26
|
+
// If already approved in this flow, let it pass to the actual handler
|
|
27
|
+
if (event.meta?.approvalStatus === 'approved') return event;
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const stateRecord = asRecord(source);
|
|
68
|
-
return asRecord(stateRecord.approvals) as Record<string, PendingApproval>;
|
|
69
|
-
};
|
|
29
|
+
// Otherwise, intercept and ask for approval via a UI widget
|
|
30
|
+
const displayData = JSON.stringify((event as any)?.data) || '';
|
|
70
31
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
approvals: Record<string, PendingApproval>,
|
|
74
|
-
): Promise<void> => {
|
|
75
|
-
if (state.threadId) {
|
|
76
|
-
await storageService.patchThreadState({
|
|
77
|
-
channelId: state.channelId,
|
|
78
|
-
threadId: state.threadId,
|
|
79
|
-
state: { approvals },
|
|
80
|
-
});
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
await storageService.patchChannelState({
|
|
84
|
-
channelId: state.channelId,
|
|
85
|
-
state: { approvals },
|
|
86
|
-
});
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const buildApprovalPlugin =
|
|
90
|
-
(rules: ApprovalRule[]): MelonyPlugin<OpenBotState, OpenBotEvent> =>
|
|
91
|
-
(builder) => {
|
|
92
|
-
for (const rule of rules) {
|
|
93
|
-
builder.on(rule.action as OpenBotEvent['type'], async function* (event, context) {
|
|
94
|
-
const meta = asRecord(event.meta);
|
|
95
|
-
if (meta.approvalStatus === 'approved') return;
|
|
96
|
-
|
|
97
|
-
const eventData = asRecord((event as { data?: unknown }).data);
|
|
98
|
-
const eventMeta = meta;
|
|
99
|
-
|
|
100
|
-
const approvalId = `approval_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
101
|
-
const widgetId = `widget_${approvalId}`;
|
|
102
|
-
const executeEvent = rule.executeEvent || rule.action;
|
|
103
|
-
const denyEvent = rule.denyEvent || `${rule.action}:result`;
|
|
104
|
-
const denyData = rule.denyData || {};
|
|
105
|
-
const hiddenKeys = new Set(rule.hiddenKeys || []);
|
|
106
|
-
const detailKeys = rule.detailKeys || Object.keys(eventData);
|
|
107
|
-
const details = detailKeys
|
|
108
|
-
.filter((key) => !hiddenKeys.has(key))
|
|
109
|
-
.map((key) => `- ${key}: ${String(eventData[key] ?? '')}`)
|
|
110
|
-
.join('\n');
|
|
111
|
-
|
|
112
|
-
const pendingApprovals = getApprovalsFromState(context.state);
|
|
113
|
-
pendingApprovals[approvalId] = {
|
|
114
|
-
id: approvalId,
|
|
115
|
-
action: rule.action,
|
|
116
|
-
executeEvent,
|
|
117
|
-
denyEvent,
|
|
118
|
-
denyData,
|
|
119
|
-
payload: eventData,
|
|
120
|
-
meta: eventMeta,
|
|
121
|
-
message: rule.message || `Approval required for ${rule.action}.`,
|
|
122
|
-
createdAt: new Date().toISOString(),
|
|
123
|
-
status: 'pending',
|
|
124
|
-
};
|
|
125
|
-
await persistApprovals(context.state, pendingApprovals);
|
|
32
|
+
const widgetId = randomUUID();
|
|
33
|
+
pendingApprovals.set(widgetId, Date.now());
|
|
126
34
|
|
|
127
|
-
|
|
35
|
+
return {
|
|
128
36
|
type: 'client:ui:widget',
|
|
129
37
|
data: {
|
|
130
|
-
kind: 'choice',
|
|
131
38
|
widgetId,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
39
|
+
kind: 'message',
|
|
40
|
+
title: `The agent wants to perform \`${action}\``,
|
|
41
|
+
body: displayData,
|
|
42
|
+
metadata: {
|
|
43
|
+
type: 'approval:request',
|
|
44
|
+
originalEvent: event,
|
|
45
|
+
},
|
|
137
46
|
actions: [
|
|
138
47
|
{ id: 'approve', label: 'Approve', variant: 'primary' },
|
|
139
48
|
{ id: 'deny', label: 'Deny', variant: 'danger' },
|
|
140
49
|
],
|
|
141
50
|
},
|
|
142
|
-
meta: {
|
|
143
|
-
} as OpenBotEvent;
|
|
144
|
-
|
|
145
|
-
yield {
|
|
146
|
-
type: 'agent:output',
|
|
147
|
-
data: { content: `Waiting for approval before running \`${rule.action}\`.` },
|
|
148
|
-
meta: { ...(event.meta || {}), agentId: context.state.agentId },
|
|
51
|
+
meta: { agentId: context.state.agentId, threadId: context.state.threadId },
|
|
149
52
|
} as OpenBotEvent;
|
|
150
|
-
|
|
151
|
-
context.suspend();
|
|
152
53
|
});
|
|
153
54
|
}
|
|
154
55
|
|
|
56
|
+
// Handle the user's response from the UI widget
|
|
155
57
|
builder.on('client:ui:widget:response', async function* (event, context) {
|
|
156
|
-
const
|
|
157
|
-
|
|
58
|
+
const { widgetId, actionId } = event.data;
|
|
59
|
+
const metadata = event.data?.metadata;
|
|
60
|
+
if (metadata?.type !== 'approval:request') return;
|
|
158
61
|
|
|
159
|
-
|
|
160
|
-
if (!
|
|
62
|
+
// Verify the widget is still pending and hasn't expired
|
|
63
|
+
if (!widgetId || !pendingApprovals.has(widgetId)) {
|
|
64
|
+
console.warn(`[approval] Received response for unknown or already handled widget: ${widgetId}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
161
67
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
type: 'agent:output',
|
|
167
|
-
data: { content: 'Approval request not found or already resolved.' },
|
|
168
|
-
meta: { ...(event.meta || {}), agentId: context.state.agentId },
|
|
169
|
-
} as OpenBotEvent;
|
|
68
|
+
const timestamp = pendingApprovals.get(widgetId)!;
|
|
69
|
+
if (Date.now() - timestamp > TTL_MS) {
|
|
70
|
+
pendingApprovals.delete(widgetId);
|
|
71
|
+
console.warn(`[approval] Received response for expired widget: ${widgetId}`);
|
|
170
72
|
return;
|
|
171
73
|
}
|
|
172
74
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
75
|
+
// Mark as handled
|
|
76
|
+
pendingApprovals.delete(widgetId);
|
|
77
|
+
|
|
78
|
+
const originalEvent = metadata.originalEvent as OpenBotEvent;
|
|
79
|
+
const approved = actionId === 'approve';
|
|
80
|
+
|
|
81
|
+
const displayData = JSON.stringify((event as any)?.data) || '';
|
|
82
|
+
|
|
83
|
+
// Yield a "responded" widget update to the UI
|
|
84
|
+
yield {
|
|
85
|
+
type: 'client:ui:widget',
|
|
86
|
+
data: {
|
|
87
|
+
widgetId,
|
|
88
|
+
kind: 'message',
|
|
89
|
+
title: `Action ${approved ? 'Approved' : 'Denied'}`,
|
|
90
|
+
body: displayData,
|
|
91
|
+
state: approved ? 'submitted' : 'cancelled',
|
|
92
|
+
display: 'collapsed',
|
|
93
|
+
disabled: true,
|
|
94
|
+
actions: [], // Clear actions to disable buttons in UI
|
|
95
|
+
},
|
|
96
|
+
meta: { agentId: context.state.agentId, threadId: context.state.threadId },
|
|
97
|
+
} as OpenBotEvent;
|
|
179
98
|
|
|
180
99
|
if (approved) {
|
|
100
|
+
// Re-emit the original event with approved status so the actual handler can run
|
|
181
101
|
yield {
|
|
182
|
-
|
|
183
|
-
data: approval.payload,
|
|
102
|
+
...originalEvent,
|
|
184
103
|
meta: {
|
|
185
|
-
...(
|
|
186
|
-
approvalId,
|
|
104
|
+
...(originalEvent.meta || {}),
|
|
187
105
|
approvalStatus: 'approved',
|
|
188
106
|
},
|
|
107
|
+
};
|
|
108
|
+
} else {
|
|
109
|
+
// Manually store the original event with denied status so it's recorded in history
|
|
110
|
+
// but NOT re-emitted to the pipeline (to avoid actual execution).
|
|
111
|
+
if (storage) {
|
|
112
|
+
await storage.storeEvent({
|
|
113
|
+
channelId: context.state.channelId,
|
|
114
|
+
threadId: context.state.threadId,
|
|
115
|
+
event: {
|
|
116
|
+
...originalEvent,
|
|
117
|
+
meta: {
|
|
118
|
+
...(originalEvent.meta || {}),
|
|
119
|
+
approvalStatus: 'denied',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Emit a failure result event for the denied action to clear the pending tool batch
|
|
126
|
+
yield {
|
|
127
|
+
type: `${originalEvent.type}:result` as OpenBotEvent['type'],
|
|
128
|
+
data: {
|
|
129
|
+
success: false,
|
|
130
|
+
error: 'Action denied by user.',
|
|
131
|
+
stderr: 'Action denied by user.',
|
|
132
|
+
output: 'Action denied by user.',
|
|
133
|
+
},
|
|
134
|
+
meta: originalEvent.meta,
|
|
189
135
|
} as OpenBotEvent;
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
yield {
|
|
194
|
-
type: approval.denyEvent as OpenBotEvent['type'],
|
|
195
|
-
data: {
|
|
196
|
-
success: false,
|
|
197
|
-
approved: false,
|
|
198
|
-
error: 'Action denied by user approval.',
|
|
199
|
-
...approval.denyData,
|
|
200
|
-
},
|
|
201
|
-
meta: { ...(approval.meta || {}), approvalId },
|
|
202
|
-
} as OpenBotEvent;
|
|
203
136
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
137
|
+
yield {
|
|
138
|
+
type: 'agent:output',
|
|
139
|
+
data: { content: `Action \`${originalEvent.type}\` was denied.` },
|
|
140
|
+
meta: { agentId: context.state.agentId },
|
|
141
|
+
} as OpenBotEvent;
|
|
142
|
+
}
|
|
209
143
|
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const readRules = (config: Record<string, unknown>): ApprovalRule[] => {
|
|
213
|
-
const raw = config.rules;
|
|
214
|
-
if (!Array.isArray(raw)) return DEFAULT_APPROVAL_RULES;
|
|
215
|
-
return raw.filter(
|
|
216
|
-
(entry): entry is ApprovalRule =>
|
|
217
|
-
!!entry && typeof entry === 'object' && typeof (entry as { action?: unknown }).action === 'string',
|
|
218
|
-
);
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
export const approvalPlugin: Plugin = {
|
|
222
|
-
id: 'approval',
|
|
223
|
-
name: 'Approval',
|
|
224
|
-
description: 'Gate protected tool calls (e.g. shell_exec) behind a UI confirmation prompt.',
|
|
225
|
-
factory: ({ config }) => buildApprovalPlugin(readRules(config)),
|
|
144
|
+
},
|
|
226
145
|
};
|
|
227
146
|
|
|
228
147
|
export default approvalPlugin;
|