@contextableai/clawg-ui 0.1.0
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 +14 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/clawgui.png +0 -0
- package/index.ts +88 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +45 -0
- package/src/channel.ts +33 -0
- package/src/client-tools.ts +42 -0
- package/src/http-handler.ts +465 -0
- package/src/tool-store.ts +104 -0
- package/tsconfig.json +12 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-02-02)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- AG-UI protocol endpoint at `/v1/agui` for OpenClaw gateway
|
|
8
|
+
- SSE streaming of agent responses as AG-UI events (`RUN_STARTED`, `TEXT_MESSAGE_START`, `TEXT_MESSAGE_CONTENT`, `TEXT_MESSAGE_END`, `TOOL_CALL_START`, `TOOL_CALL_END`, `RUN_FINISHED`, `RUN_ERROR`)
|
|
9
|
+
- Bearer token authentication using the gateway token
|
|
10
|
+
- Content negotiation via `@ag-ui/encoder` (SSE and protobuf support)
|
|
11
|
+
- Standard OpenClaw channel plugin (`agui`) for gateway status visibility
|
|
12
|
+
- Agent routing via `X-OpenClaw-Agent-Id` header
|
|
13
|
+
- Abort on client disconnect
|
|
14
|
+
- Compatible with `@ag-ui/client` `HttpAgent`, CopilotKit, and any AG-UI consumer
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Contextable
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# clawg-ui
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
An [OpenClaw](https://github.com/openclaw/openclaw) channel plugin that exposes the gateway as an [AG-UI](https://docs.ag-ui.com) protocol-compatible HTTP endpoint. AG-UI clients such as [CopilotKit](https://www.copilotkit.ai) UIs and `@ag-ui/client` `HttpAgent` instances can connect to OpenClaw and receive streamed responses.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install contextable/clawg-ui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or with the OpenClaw plugin CLI:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install contextable/clawg-ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then restart the gateway. The plugin auto-registers the `/v1/agui` endpoint and the `agui` channel.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
The plugin registers as an OpenClaw channel and adds an HTTP route at `/v1/agui`. When an AG-UI client POSTs a `RunAgentInput` payload, the plugin:
|
|
24
|
+
|
|
25
|
+
1. Authenticates the request using the gateway bearer token
|
|
26
|
+
2. Parses the AG-UI messages into an OpenClaw inbound context
|
|
27
|
+
3. Routes to the appropriate agent via the gateway's standard routing
|
|
28
|
+
4. Dispatches the message through the reply pipeline (same path as Telegram, Teams, etc.)
|
|
29
|
+
5. Streams the agent's response back as AG-UI SSE events
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
AG-UI Client OpenClaw Gateway
|
|
33
|
+
| |
|
|
34
|
+
| POST /v1/agui (RunAgentInput) |
|
|
35
|
+
|------------------------------------->|
|
|
36
|
+
| | Auth (bearer token)
|
|
37
|
+
| | Route to agent
|
|
38
|
+
| | Dispatch inbound message
|
|
39
|
+
| |
|
|
40
|
+
| SSE: RUN_STARTED |
|
|
41
|
+
|<-------------------------------------|
|
|
42
|
+
| SSE: TEXT_MESSAGE_START |
|
|
43
|
+
|<-------------------------------------|
|
|
44
|
+
| SSE: TEXT_MESSAGE_CONTENT (delta) |
|
|
45
|
+
|<-------------------------------------| (streamed chunks)
|
|
46
|
+
| SSE: TEXT_MESSAGE_CONTENT (delta) |
|
|
47
|
+
|<-------------------------------------|
|
|
48
|
+
| SSE: TOOL_CALL_START |
|
|
49
|
+
|<-------------------------------------| (if agent uses tools)
|
|
50
|
+
| SSE: TOOL_CALL_END |
|
|
51
|
+
|<-------------------------------------|
|
|
52
|
+
| SSE: TEXT_MESSAGE_END |
|
|
53
|
+
|<-------------------------------------|
|
|
54
|
+
| SSE: RUN_FINISHED |
|
|
55
|
+
|<-------------------------------------|
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
### Prerequisites
|
|
61
|
+
|
|
62
|
+
- OpenClaw gateway running (`openclaw gateway run`)
|
|
63
|
+
- A gateway auth token configured (`OPENCLAW_GATEWAY_TOKEN` env var or `gateway.auth.token` in config)
|
|
64
|
+
|
|
65
|
+
### curl
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl -N -X POST http://localhost:18789/v1/agui \
|
|
69
|
+
-H "Content-Type: application/json" \
|
|
70
|
+
-H "Accept: text/event-stream" \
|
|
71
|
+
-H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
|
|
72
|
+
-d '{
|
|
73
|
+
"threadId": "thread-1",
|
|
74
|
+
"runId": "run-1",
|
|
75
|
+
"messages": [
|
|
76
|
+
{"role": "user", "content": "What is the weather in San Francisco?"}
|
|
77
|
+
]
|
|
78
|
+
}'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### @ag-ui/client HttpAgent
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { HttpAgent } from "@ag-ui/client";
|
|
85
|
+
|
|
86
|
+
const agent = new HttpAgent({
|
|
87
|
+
url: "http://localhost:18789/v1/agui",
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${process.env.OPENCLAW_GATEWAY_TOKEN}`,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const stream = agent.run({
|
|
94
|
+
threadId: "thread-1",
|
|
95
|
+
runId: "run-1",
|
|
96
|
+
messages: [
|
|
97
|
+
{ role: "user", content: "Hello from AG-UI" },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for await (const event of stream) {
|
|
102
|
+
console.log(event.type, event);
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### CopilotKit
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { CopilotKit } from "@copilotkit/react-core";
|
|
110
|
+
|
|
111
|
+
function App() {
|
|
112
|
+
return (
|
|
113
|
+
<CopilotKit
|
|
114
|
+
runtimeUrl="http://localhost:18789/v1/agui"
|
|
115
|
+
headers={{
|
|
116
|
+
Authorization: `Bearer ${process.env.OPENCLAW_GATEWAY_TOKEN}`,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{/* your app */}
|
|
120
|
+
</CopilotKit>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Request format
|
|
126
|
+
|
|
127
|
+
The endpoint accepts a POST with a JSON body matching the AG-UI `RunAgentInput` schema:
|
|
128
|
+
|
|
129
|
+
| Field | Type | Required | Description |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `threadId` | string | no | Conversation thread ID. Auto-generated if omitted. |
|
|
132
|
+
| `runId` | string | no | Unique run ID. Auto-generated if omitted. |
|
|
133
|
+
| `messages` | Message[] | yes | Array of messages. At least one `user` message required. |
|
|
134
|
+
| `tools` | Tool[] | no | Client-side tool definitions (reserved for future use). |
|
|
135
|
+
| `state` | object | no | Client state (reserved for future use). |
|
|
136
|
+
|
|
137
|
+
### Message format
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"role": "user",
|
|
142
|
+
"content": "Hello"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Supported roles: `user`, `assistant`, `system`, `tool`.
|
|
147
|
+
|
|
148
|
+
## Response format
|
|
149
|
+
|
|
150
|
+
The response is an SSE stream. Each event is a `data:` line containing a JSON object with a `type` field from the AG-UI `EventType` enum:
|
|
151
|
+
|
|
152
|
+
| Event | When |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `RUN_STARTED` | Immediately after validation |
|
|
155
|
+
| `TEXT_MESSAGE_START` | First assistant text chunk |
|
|
156
|
+
| `TEXT_MESSAGE_CONTENT` | Each streamed text delta |
|
|
157
|
+
| `TEXT_MESSAGE_END` | After last text chunk |
|
|
158
|
+
| `TOOL_CALL_START` | Agent invokes a tool |
|
|
159
|
+
| `TOOL_CALL_END` | Tool execution complete |
|
|
160
|
+
| `RUN_FINISHED` | Agent run complete |
|
|
161
|
+
| `RUN_ERROR` | On failure |
|
|
162
|
+
|
|
163
|
+
## Authentication
|
|
164
|
+
|
|
165
|
+
The endpoint uses the same bearer token as the OpenClaw gateway. Set it via:
|
|
166
|
+
|
|
167
|
+
- Environment variable: `OPENCLAW_GATEWAY_TOKEN`
|
|
168
|
+
- Config file: `gateway.auth.token`
|
|
169
|
+
|
|
170
|
+
Pass it in the `Authorization` header:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
Authorization: Bearer <token>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Agent routing
|
|
177
|
+
|
|
178
|
+
The plugin uses OpenClaw's standard agent routing. By default, messages route to the `main` agent. To target a specific agent, set the `X-OpenClaw-Agent-Id` header:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
curl -N -X POST http://localhost:18789/v1/agui \
|
|
182
|
+
-H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
|
|
183
|
+
-H "X-OpenClaw-Agent-Id: my-agent" \
|
|
184
|
+
-d '{"messages":[{"role":"user","content":"Hello"}]}'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Error responses
|
|
188
|
+
|
|
189
|
+
Non-streaming errors return JSON:
|
|
190
|
+
|
|
191
|
+
| Status | Meaning |
|
|
192
|
+
|---|---|
|
|
193
|
+
| 400 | Invalid request (missing messages, bad JSON) |
|
|
194
|
+
| 401 | Unauthorized (missing or invalid token) |
|
|
195
|
+
| 405 | Method not allowed (only POST accepted) |
|
|
196
|
+
|
|
197
|
+
Streaming errors emit a `RUN_ERROR` event and close the connection.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
git clone https://github.com/contextable/clawg-ui
|
|
203
|
+
cd clawg-ui
|
|
204
|
+
npm install
|
|
205
|
+
npm test
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
package/clawgui.png
ADDED
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { EventType } from "@ag-ui/core";
|
|
5
|
+
import { aguiChannelPlugin } from "./src/channel.js";
|
|
6
|
+
import { createAguiHttpHandler } from "./src/http-handler.js";
|
|
7
|
+
import { clawgUiToolFactory } from "./src/client-tools.js";
|
|
8
|
+
import {
|
|
9
|
+
getWriter,
|
|
10
|
+
pushToolCallId,
|
|
11
|
+
popToolCallId,
|
|
12
|
+
isClientTool,
|
|
13
|
+
setClientToolCalled,
|
|
14
|
+
} from "./src/tool-store.js";
|
|
15
|
+
|
|
16
|
+
const plugin = {
|
|
17
|
+
id: "clawg-ui",
|
|
18
|
+
name: "AG-UI",
|
|
19
|
+
description: "AG-UI protocol endpoint for CopilotKit and HttpAgent clients",
|
|
20
|
+
configSchema: emptyPluginConfigSchema(),
|
|
21
|
+
register(api: OpenClawPluginApi) {
|
|
22
|
+
api.registerChannel({ plugin: aguiChannelPlugin });
|
|
23
|
+
api.registerTool(clawgUiToolFactory);
|
|
24
|
+
api.registerHttpRoute({
|
|
25
|
+
path: "/v1/clawg-ui",
|
|
26
|
+
handler: createAguiHttpHandler(api),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Emit TOOL_CALL_START + TOOL_CALL_ARGS from before_tool_call hook.
|
|
30
|
+
// For client tools: also emit TOOL_CALL_END immediately (fire-and-forget).
|
|
31
|
+
// For server tools: TOOL_CALL_END is emitted later by tool_result_persist.
|
|
32
|
+
api.on("before_tool_call", (event, ctx) => {
|
|
33
|
+
const sk = ctx.sessionKey;
|
|
34
|
+
if (!sk) return;
|
|
35
|
+
const writer = getWriter(sk);
|
|
36
|
+
if (!writer) return;
|
|
37
|
+
const toolCallId = `tool-${randomUUID()}`;
|
|
38
|
+
writer({
|
|
39
|
+
type: EventType.TOOL_CALL_START,
|
|
40
|
+
toolCallId,
|
|
41
|
+
toolCallName: event.toolName,
|
|
42
|
+
});
|
|
43
|
+
if (event.params && Object.keys(event.params).length > 0) {
|
|
44
|
+
writer({
|
|
45
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
46
|
+
toolCallId,
|
|
47
|
+
delta: JSON.stringify(event.params),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (isClientTool(sk, event.toolName)) {
|
|
52
|
+
// Client tool: emit TOOL_CALL_END now. The run will finish and the
|
|
53
|
+
// client initiates a new run with the tool result.
|
|
54
|
+
writer({
|
|
55
|
+
type: EventType.TOOL_CALL_END,
|
|
56
|
+
toolCallId,
|
|
57
|
+
});
|
|
58
|
+
setClientToolCalled(sk);
|
|
59
|
+
} else {
|
|
60
|
+
// Server tool: push ID so tool_result_persist can emit
|
|
61
|
+
// TOOL_CALL_RESULT + TOOL_CALL_END after execute() completes.
|
|
62
|
+
pushToolCallId(sk, toolCallId);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Emit TOOL_CALL_RESULT + TOOL_CALL_END for server-side tools only.
|
|
67
|
+
// Client tools already emitted TOOL_CALL_END in before_tool_call.
|
|
68
|
+
api.on("tool_result_persist", (_event, ctx) => {
|
|
69
|
+
const sk = ctx.sessionKey;
|
|
70
|
+
if (!sk) return;
|
|
71
|
+
const writer = getWriter(sk);
|
|
72
|
+
const toolCallId = popToolCallId(sk);
|
|
73
|
+
if (writer && toolCallId) {
|
|
74
|
+
writer({
|
|
75
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
76
|
+
toolCallId,
|
|
77
|
+
content: "",
|
|
78
|
+
});
|
|
79
|
+
writer({
|
|
80
|
+
type: EventType.TOOL_CALL_END,
|
|
81
|
+
toolCallId,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contextableai/clawg-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AG-UI protocol channel plugin for OpenClaw — connect CopilotKit and AG-UI clients to your OpenClaw gateway",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/contextable/clawg-ui"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"openclaw",
|
|
13
|
+
"ag-ui",
|
|
14
|
+
"copilotkit",
|
|
15
|
+
"sse",
|
|
16
|
+
"agent",
|
|
17
|
+
"channel-plugin"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@ag-ui/core": "^0.0.43",
|
|
24
|
+
"@ag-ui/encoder": "^0.0.43"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"openclaw": "*"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"vitest": "^3.0.0"
|
|
31
|
+
},
|
|
32
|
+
"openclaw": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./index.ts"
|
|
35
|
+
],
|
|
36
|
+
"channel": {
|
|
37
|
+
"id": "clawg-ui",
|
|
38
|
+
"label": "AG-UI",
|
|
39
|
+
"docsPath": "/channels/agui",
|
|
40
|
+
"docsLabel": "agui",
|
|
41
|
+
"blurb": "AG-UI protocol endpoint for CopilotKit and HttpAgent clients.",
|
|
42
|
+
"order": 90
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
type ResolvedAguiAccount = {
|
|
4
|
+
accountId: string;
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
configured: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const aguiChannelPlugin: ChannelPlugin<ResolvedAguiAccount> = {
|
|
10
|
+
id: "clawg-ui",
|
|
11
|
+
meta: {
|
|
12
|
+
id: "clawg-ui",
|
|
13
|
+
label: "AG-UI",
|
|
14
|
+
selectionLabel: "AG-UI (CopilotKit / HttpAgent)",
|
|
15
|
+
docsPath: "/channels/agui",
|
|
16
|
+
docsLabel: "agui",
|
|
17
|
+
blurb: "AG-UI protocol endpoint for CopilotKit and HttpAgent clients.",
|
|
18
|
+
order: 90,
|
|
19
|
+
},
|
|
20
|
+
capabilities: {
|
|
21
|
+
chatTypes: ["direct"],
|
|
22
|
+
blockStreaming: true,
|
|
23
|
+
},
|
|
24
|
+
config: {
|
|
25
|
+
listAccountIds: () => ["default"],
|
|
26
|
+
resolveAccount: () => ({
|
|
27
|
+
accountId: "default",
|
|
28
|
+
enabled: true,
|
|
29
|
+
configured: true,
|
|
30
|
+
}),
|
|
31
|
+
defaultAccountId: () => "default",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { popTools } from "./tool-store.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin tool factory registered via `api.registerTool`.
|
|
5
|
+
* Receives the full `OpenClawPluginToolContext` including `sessionKey`,
|
|
6
|
+
* so it's fully reentrant across concurrent requests.
|
|
7
|
+
*
|
|
8
|
+
* Returns AG-UI client-provided tools converted to agent tools,
|
|
9
|
+
* or null if no client tools were stashed for this session.
|
|
10
|
+
*/
|
|
11
|
+
export function clawgUiToolFactory(ctx: { sessionKey?: string }) {
|
|
12
|
+
const sessionKey = ctx.sessionKey;
|
|
13
|
+
if (!sessionKey) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const clientTools = popTools(sessionKey);
|
|
17
|
+
if (clientTools.length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return clientTools.map((t) => ({
|
|
21
|
+
name: t.name,
|
|
22
|
+
label: t.name,
|
|
23
|
+
description: t.description,
|
|
24
|
+
parameters: t.parameters ?? { type: "object", properties: {} },
|
|
25
|
+
async execute(_toolCallId: string, args: unknown) {
|
|
26
|
+
// Client-side tools are fire-and-forget per AG-UI protocol.
|
|
27
|
+
// TOOL_CALL_START/ARGS/END are emitted by the before_tool_call hook.
|
|
28
|
+
// The run ends, and the client initiates a new run with the tool result.
|
|
29
|
+
// Return args so the agent loop can continue (the dispatcher will
|
|
30
|
+
// suppress any text output after a client tool call).
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text" as const,
|
|
35
|
+
text: JSON.stringify(args),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
details: { clientTool: true, name: t.name, args },
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { EventType } from "@ag-ui/core";
|
|
4
|
+
import type { RunAgentInput, Message } from "@ag-ui/core";
|
|
5
|
+
import { EventEncoder } from "@ag-ui/encoder";
|
|
6
|
+
import type { OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk";
|
|
7
|
+
import {
|
|
8
|
+
stashTools,
|
|
9
|
+
setWriter,
|
|
10
|
+
clearWriter,
|
|
11
|
+
markClientToolNames,
|
|
12
|
+
wasClientToolCalled,
|
|
13
|
+
clearClientToolCalled,
|
|
14
|
+
clearClientToolNames,
|
|
15
|
+
} from "./tool-store.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Lightweight HTTP helpers (no internal imports needed)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
22
|
+
res.statusCode = status;
|
|
23
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
24
|
+
res.end(JSON.stringify(body));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sendMethodNotAllowed(res: ServerResponse) {
|
|
28
|
+
res.setHeader("Allow", "POST");
|
|
29
|
+
res.statusCode = 405;
|
|
30
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
31
|
+
res.end("Method Not Allowed");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendUnauthorized(res: ServerResponse) {
|
|
35
|
+
sendJson(res, 401, { error: { message: "Unauthorized", type: "unauthorized" } });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const chunks: Buffer[] = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
req.on("data", (chunk: Buffer) => {
|
|
43
|
+
size += chunk.length;
|
|
44
|
+
if (size > maxBytes) {
|
|
45
|
+
reject(new Error("Request body too large"));
|
|
46
|
+
req.destroy();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
chunks.push(chunk);
|
|
50
|
+
});
|
|
51
|
+
req.on("end", () => {
|
|
52
|
+
try {
|
|
53
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
|
54
|
+
} catch {
|
|
55
|
+
reject(new Error("Invalid JSON body"));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
req.on("error", reject);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getBearerToken(req: IncomingMessage): string | undefined {
|
|
63
|
+
const raw = req.headers.authorization?.trim() ?? "";
|
|
64
|
+
if (!raw.toLowerCase().startsWith("bearer ")) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return raw.slice(7).trim() || undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Extract text from AG-UI messages
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function extractTextContent(msg: Message): string {
|
|
75
|
+
if (typeof msg.content === "string") {
|
|
76
|
+
return msg.content;
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Build MsgContext-compatible body from AG-UI messages
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function buildBodyFromMessages(messages: Message[]): {
|
|
86
|
+
body: string;
|
|
87
|
+
systemPrompt?: string;
|
|
88
|
+
} {
|
|
89
|
+
const systemParts: string[] = [];
|
|
90
|
+
const parts: string[] = [];
|
|
91
|
+
let lastUserBody = "";
|
|
92
|
+
let lastToolBody = "";
|
|
93
|
+
|
|
94
|
+
for (const msg of messages) {
|
|
95
|
+
const role = msg.role?.trim() ?? "";
|
|
96
|
+
const content = extractTextContent(msg).trim();
|
|
97
|
+
// Allow messages with no content (e.g., assistant with only toolCalls)
|
|
98
|
+
if (!role) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (role === "system") {
|
|
102
|
+
if (content) systemParts.push(content);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (role === "user") {
|
|
106
|
+
lastUserBody = content;
|
|
107
|
+
if (content) parts.push(`User: ${content}`);
|
|
108
|
+
} else if (role === "assistant") {
|
|
109
|
+
if (content) parts.push(`Assistant: ${content}`);
|
|
110
|
+
} else if (role === "tool") {
|
|
111
|
+
lastToolBody = content;
|
|
112
|
+
if (content) parts.push(`Tool result: ${content}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If there's only a single user message, use it directly (no envelope needed)
|
|
117
|
+
// If there's only a tool result (resuming after client tool), use it directly
|
|
118
|
+
const userMessages = messages.filter((m) => m.role === "user");
|
|
119
|
+
const toolMessages = messages.filter((m) => m.role === "tool");
|
|
120
|
+
let body: string;
|
|
121
|
+
if (userMessages.length === 1 && parts.length === 1) {
|
|
122
|
+
body = lastUserBody;
|
|
123
|
+
} else if (userMessages.length === 0 && toolMessages.length > 0 && parts.length === toolMessages.length) {
|
|
124
|
+
// Tool-result-only submission: format as tool result for agent context
|
|
125
|
+
body = `Tool result: ${lastToolBody}`;
|
|
126
|
+
} else {
|
|
127
|
+
body = parts.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
body,
|
|
132
|
+
systemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Token-based auth check against gateway config
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function authenticateRequest(
|
|
141
|
+
req: IncomingMessage,
|
|
142
|
+
api: OpenClawPluginApi,
|
|
143
|
+
): boolean {
|
|
144
|
+
const token = getBearerToken(req);
|
|
145
|
+
if (!token) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
// Read the configured gateway token from config
|
|
149
|
+
const gatewayAuth = api.config.gateway?.auth;
|
|
150
|
+
const configuredToken =
|
|
151
|
+
(gatewayAuth as Record<string, unknown> | undefined)?.token ??
|
|
152
|
+
process.env.OPENCLAW_GATEWAY_TOKEN ??
|
|
153
|
+
process.env.CLAWDBOT_GATEWAY_TOKEN;
|
|
154
|
+
if (typeof configuredToken !== "string" || !configuredToken) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
// Constant-time comparison
|
|
158
|
+
if (token.length !== configuredToken.length) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
let mismatch = 0;
|
|
162
|
+
for (let i = 0; i < token.length; i++) {
|
|
163
|
+
mismatch |= token.charCodeAt(i) ^ configuredToken.charCodeAt(i);
|
|
164
|
+
}
|
|
165
|
+
return mismatch === 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// HTTP handler factory
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export function createAguiHttpHandler(api: OpenClawPluginApi) {
|
|
173
|
+
const runtime: PluginRuntime = api.runtime;
|
|
174
|
+
|
|
175
|
+
return async function handleAguiRequest(
|
|
176
|
+
req: IncomingMessage,
|
|
177
|
+
res: ServerResponse,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
// POST-only
|
|
180
|
+
if (req.method !== "POST") {
|
|
181
|
+
sendMethodNotAllowed(res);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Auth
|
|
186
|
+
if (!authenticateRequest(req, api)) {
|
|
187
|
+
sendUnauthorized(res);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Parse body
|
|
192
|
+
let body: unknown;
|
|
193
|
+
try {
|
|
194
|
+
body = await readJsonBody(req, 1024 * 1024);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
sendJson(res, 400, {
|
|
197
|
+
error: { message: String(err), type: "invalid_request_error" },
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const input = body as RunAgentInput;
|
|
203
|
+
const threadId = input.threadId || `clawg-ui-${randomUUID()}`;
|
|
204
|
+
const runId = input.runId || `clawg-ui-run-${randomUUID()}`;
|
|
205
|
+
|
|
206
|
+
// Validate messages
|
|
207
|
+
const messages: Message[] = Array.isArray(input.messages)
|
|
208
|
+
? input.messages
|
|
209
|
+
: [];
|
|
210
|
+
|
|
211
|
+
const hasUserMessage = messages.some((m) => m.role === "user");
|
|
212
|
+
const hasToolMessage = messages.some((m) => m.role === "tool");
|
|
213
|
+
if (!hasUserMessage && !hasToolMessage) {
|
|
214
|
+
sendJson(res, 400, {
|
|
215
|
+
error: {
|
|
216
|
+
message: "At least one user or tool message is required in `messages`.",
|
|
217
|
+
type: "invalid_request_error",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Build body from messages
|
|
224
|
+
const { body: messageBody } = buildBodyFromMessages(messages);
|
|
225
|
+
if (!messageBody.trim()) {
|
|
226
|
+
sendJson(res, 400, {
|
|
227
|
+
error: {
|
|
228
|
+
message: "Could not extract a prompt from `messages`.",
|
|
229
|
+
type: "invalid_request_error",
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Resolve agent route
|
|
236
|
+
const cfg = runtime.config.loadConfig();
|
|
237
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
238
|
+
cfg,
|
|
239
|
+
channel: "clawg-ui",
|
|
240
|
+
peer: { kind: "dm", id: `clawg-ui-${threadId}` },
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Set up SSE via EventEncoder
|
|
244
|
+
const accept =
|
|
245
|
+
typeof req.headers.accept === "string"
|
|
246
|
+
? req.headers.accept
|
|
247
|
+
: "text/event-stream";
|
|
248
|
+
const encoder = new EventEncoder({ accept });
|
|
249
|
+
res.statusCode = 200;
|
|
250
|
+
res.setHeader("Content-Type", encoder.getContentType());
|
|
251
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
252
|
+
res.setHeader("Connection", "keep-alive");
|
|
253
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
254
|
+
res.flushHeaders?.();
|
|
255
|
+
|
|
256
|
+
let closed = false;
|
|
257
|
+
const messageId = `msg-${randomUUID()}`;
|
|
258
|
+
let messageStarted = false;
|
|
259
|
+
|
|
260
|
+
const writeEvent = (event: Record<string, unknown>) => {
|
|
261
|
+
if (closed) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
res.write(encoder.encode(event));
|
|
266
|
+
} catch {
|
|
267
|
+
// Client may have disconnected
|
|
268
|
+
closed = true;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Handle client disconnect
|
|
273
|
+
req.on("close", () => {
|
|
274
|
+
closed = true;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Emit RUN_STARTED
|
|
278
|
+
writeEvent({
|
|
279
|
+
type: EventType.RUN_STARTED,
|
|
280
|
+
threadId,
|
|
281
|
+
runId,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Build inbound context using the plugin runtime (same pattern as msteams)
|
|
285
|
+
const sessionKey = route.sessionKey;
|
|
286
|
+
|
|
287
|
+
// Stash client-provided tools so the plugin tool factory can pick them up
|
|
288
|
+
if (Array.isArray(input.tools) && input.tools.length > 0) {
|
|
289
|
+
stashTools(sessionKey, input.tools);
|
|
290
|
+
markClientToolNames(
|
|
291
|
+
sessionKey,
|
|
292
|
+
input.tools.map((t: { name: string }) => t.name),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Register SSE writer so before/after_tool_call hooks can emit AG-UI events
|
|
297
|
+
setWriter(sessionKey, writeEvent);
|
|
298
|
+
const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
|
|
299
|
+
agentId: route.agentId,
|
|
300
|
+
});
|
|
301
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
302
|
+
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
|
303
|
+
storePath,
|
|
304
|
+
sessionKey,
|
|
305
|
+
});
|
|
306
|
+
const envelopedBody = runtime.channel.reply.formatAgentEnvelope({
|
|
307
|
+
channel: "AG-UI",
|
|
308
|
+
from: "User",
|
|
309
|
+
timestamp: new Date(),
|
|
310
|
+
previousTimestamp,
|
|
311
|
+
envelope: envelopeOptions,
|
|
312
|
+
body: messageBody,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
316
|
+
Body: envelopedBody,
|
|
317
|
+
RawBody: messageBody,
|
|
318
|
+
CommandBody: messageBody,
|
|
319
|
+
From: `clawg-ui:${threadId}`,
|
|
320
|
+
To: "clawg-ui",
|
|
321
|
+
SessionKey: sessionKey,
|
|
322
|
+
ChatType: "direct",
|
|
323
|
+
ConversationLabel: "AG-UI",
|
|
324
|
+
SenderName: "AG-UI Client",
|
|
325
|
+
SenderId: `clawg-ui-${threadId}`,
|
|
326
|
+
Provider: "clawg-ui" as const,
|
|
327
|
+
Surface: "clawg-ui" as const,
|
|
328
|
+
MessageSid: runId,
|
|
329
|
+
Timestamp: Date.now(),
|
|
330
|
+
WasMentioned: true,
|
|
331
|
+
CommandAuthorized: true,
|
|
332
|
+
OriginatingChannel: "clawg-ui" as const,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Record inbound session
|
|
336
|
+
await runtime.channel.session.recordInboundSession({
|
|
337
|
+
storePath,
|
|
338
|
+
sessionKey,
|
|
339
|
+
ctx: ctxPayload,
|
|
340
|
+
onRecordError: () => {},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Create reply dispatcher — translates reply payloads into AG-UI SSE events
|
|
344
|
+
const abortController = new AbortController();
|
|
345
|
+
req.on("close", () => {
|
|
346
|
+
abortController.abort();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const dispatcher = {
|
|
350
|
+
sendToolResult: (_payload: { text?: string }) => {
|
|
351
|
+
// Tool call events are emitted by before/after_tool_call hooks
|
|
352
|
+
return !closed;
|
|
353
|
+
},
|
|
354
|
+
sendBlockReply: (payload: { text?: string }) => {
|
|
355
|
+
if (closed || wasClientToolCalled(sessionKey)) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
const text = payload.text?.trim();
|
|
359
|
+
if (!text) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (!messageStarted) {
|
|
363
|
+
messageStarted = true;
|
|
364
|
+
writeEvent({
|
|
365
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
366
|
+
messageId,
|
|
367
|
+
role: "assistant",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
writeEvent({
|
|
371
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
372
|
+
messageId,
|
|
373
|
+
delta: text,
|
|
374
|
+
});
|
|
375
|
+
return true;
|
|
376
|
+
},
|
|
377
|
+
sendFinalReply: (payload: { text?: string }) => {
|
|
378
|
+
if (closed) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
const text = wasClientToolCalled(sessionKey) ? "" : payload.text?.trim();
|
|
382
|
+
if (text) {
|
|
383
|
+
if (!messageStarted) {
|
|
384
|
+
messageStarted = true;
|
|
385
|
+
writeEvent({
|
|
386
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
387
|
+
messageId,
|
|
388
|
+
role: "assistant",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
writeEvent({
|
|
392
|
+
type: EventType.TEXT_MESSAGE_CONTENT,
|
|
393
|
+
messageId,
|
|
394
|
+
delta: text,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// End the message and run
|
|
398
|
+
if (messageStarted) {
|
|
399
|
+
writeEvent({
|
|
400
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
401
|
+
messageId,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
writeEvent({
|
|
405
|
+
type: EventType.RUN_FINISHED,
|
|
406
|
+
threadId,
|
|
407
|
+
runId,
|
|
408
|
+
});
|
|
409
|
+
closed = true;
|
|
410
|
+
res.end();
|
|
411
|
+
return true;
|
|
412
|
+
},
|
|
413
|
+
waitForIdle: () => Promise.resolve(),
|
|
414
|
+
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Dispatch the inbound message — this triggers the agent run
|
|
418
|
+
try {
|
|
419
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
420
|
+
ctx: ctxPayload,
|
|
421
|
+
cfg,
|
|
422
|
+
dispatcher,
|
|
423
|
+
replyOptions: {
|
|
424
|
+
runId,
|
|
425
|
+
abortSignal: abortController.signal,
|
|
426
|
+
disableBlockStreaming: false,
|
|
427
|
+
onAgentRunStart: () => {},
|
|
428
|
+
onToolResult: () => {
|
|
429
|
+
// Tool call events are emitted by before/after_tool_call hooks
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// If the dispatcher's final reply didn't close the stream, close it now
|
|
435
|
+
if (!closed) {
|
|
436
|
+
if (messageStarted) {
|
|
437
|
+
writeEvent({
|
|
438
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
439
|
+
messageId,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
writeEvent({
|
|
443
|
+
type: EventType.RUN_FINISHED,
|
|
444
|
+
threadId,
|
|
445
|
+
runId,
|
|
446
|
+
});
|
|
447
|
+
closed = true;
|
|
448
|
+
res.end();
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
if (!closed) {
|
|
452
|
+
writeEvent({
|
|
453
|
+
type: EventType.RUN_ERROR,
|
|
454
|
+
message: String(err),
|
|
455
|
+
});
|
|
456
|
+
closed = true;
|
|
457
|
+
res.end();
|
|
458
|
+
}
|
|
459
|
+
} finally {
|
|
460
|
+
clearWriter(sessionKey);
|
|
461
|
+
clearClientToolCalled(sessionKey);
|
|
462
|
+
clearClientToolNames(sessionKey);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Tool } from "@ag-ui/core";
|
|
2
|
+
|
|
3
|
+
export type EventWriter = (event: Record<string, unknown>) => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-session store for:
|
|
7
|
+
* 1. AG-UI client-provided tools (read by the plugin tool factory)
|
|
8
|
+
* 2. SSE event writer (read by before/after_tool_call hooks)
|
|
9
|
+
*
|
|
10
|
+
* Fully reentrant — concurrent requests use different session keys.
|
|
11
|
+
*/
|
|
12
|
+
const toolStore = new Map<string, Tool[]>();
|
|
13
|
+
const writerStore = new Map<string, EventWriter>();
|
|
14
|
+
|
|
15
|
+
// --- Client tools (for the plugin tool factory) ---
|
|
16
|
+
|
|
17
|
+
export function stashTools(sessionKey: string, tools: Tool[]): void {
|
|
18
|
+
toolStore.set(sessionKey, tools);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function popTools(sessionKey: string): Tool[] {
|
|
22
|
+
const tools = toolStore.get(sessionKey) ?? [];
|
|
23
|
+
toolStore.delete(sessionKey);
|
|
24
|
+
return tools;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- SSE event writer (for before/after_tool_call hooks) ---
|
|
28
|
+
|
|
29
|
+
export function setWriter(sessionKey: string, writer: EventWriter): void {
|
|
30
|
+
writerStore.set(sessionKey, writer);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getWriter(sessionKey: string): EventWriter | undefined {
|
|
34
|
+
return writerStore.get(sessionKey);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function clearWriter(sessionKey: string): void {
|
|
38
|
+
writerStore.delete(sessionKey);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Pending toolCallId stack (before_tool_call pushes, tool_result_persist pops) ---
|
|
42
|
+
// Only used for SERVER-side tools. Client tools emit TOOL_CALL_END in
|
|
43
|
+
// before_tool_call and never push to this stack.
|
|
44
|
+
|
|
45
|
+
const pendingStacks = new Map<string, string[]>();
|
|
46
|
+
|
|
47
|
+
export function pushToolCallId(sessionKey: string, toolCallId: string): void {
|
|
48
|
+
let stack = pendingStacks.get(sessionKey);
|
|
49
|
+
if (!stack) {
|
|
50
|
+
stack = [];
|
|
51
|
+
pendingStacks.set(sessionKey, stack);
|
|
52
|
+
}
|
|
53
|
+
stack.push(toolCallId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function popToolCallId(sessionKey: string): string | undefined {
|
|
57
|
+
const stack = pendingStacks.get(sessionKey);
|
|
58
|
+
const id = stack?.pop();
|
|
59
|
+
if (stack && stack.length === 0) {
|
|
60
|
+
pendingStacks.delete(sessionKey);
|
|
61
|
+
}
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Client tool name tracking ---
|
|
66
|
+
// Tracks which tool names are client-provided so hooks can distinguish them.
|
|
67
|
+
|
|
68
|
+
const clientToolNames = new Map<string, Set<string>>();
|
|
69
|
+
|
|
70
|
+
export function markClientToolNames(
|
|
71
|
+
sessionKey: string,
|
|
72
|
+
names: string[],
|
|
73
|
+
): void {
|
|
74
|
+
clientToolNames.set(sessionKey, new Set(names));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isClientTool(
|
|
78
|
+
sessionKey: string,
|
|
79
|
+
toolName: string,
|
|
80
|
+
): boolean {
|
|
81
|
+
return clientToolNames.get(sessionKey)?.has(toolName) ?? false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function clearClientToolNames(sessionKey: string): void {
|
|
85
|
+
clientToolNames.delete(sessionKey);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Client-tool-called flag ---
|
|
89
|
+
// Set when a client tool is invoked during a run so the dispatcher can
|
|
90
|
+
// suppress text output and end the run after the tool call events.
|
|
91
|
+
|
|
92
|
+
const clientToolCalledFlags = new Map<string, boolean>();
|
|
93
|
+
|
|
94
|
+
export function setClientToolCalled(sessionKey: string): void {
|
|
95
|
+
clientToolCalledFlags.set(sessionKey, true);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function wasClientToolCalled(sessionKey: string): boolean {
|
|
99
|
+
return clientToolCalledFlags.get(sessionKey) ?? false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearClientToolCalled(sessionKey: string): void {
|
|
103
|
+
clientToolCalledFlags.delete(sessionKey);
|
|
104
|
+
}
|
package/tsconfig.json
ADDED