@agentic-kit/react 0.2.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/LICENSE +22 -0
- package/README.md +190 -0
- package/esm/index.d.ts +1 -0
- package/esm/index.js +1 -0
- package/esm/package.json +1 -0
- package/esm/use-chat.d.ts +42 -0
- package/esm/use-chat.js +331 -0
- package/index.d.ts +1 -0
- package/index.js +5 -0
- package/package.json +58 -0
- package/use-chat.d.ts +42 -0
- package/use-chat.js +334 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
|
|
4
|
+
Copyright (c) 2025 Constructive <developers@constructive.io>
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# @agentic-kit/react
|
|
2
|
+
|
|
3
|
+
<p align="center" width="100%">
|
|
4
|
+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center" width="100%">
|
|
8
|
+
<a href="https://github.com/constructive-io/agentic-kit/actions/workflows/run-tests.yaml">
|
|
9
|
+
<img height="20" src="https://github.com/constructive-io/agentic-kit/actions/workflows/run-tests.yaml/badge.svg" />
|
|
10
|
+
</a>
|
|
11
|
+
<a href="https://github.com/constructive-io/agentic-kit/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/@agentic-kit/react"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/agentic-kit?filename=packages%2Freact%2Fpackage.json"/></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
Headless React bindings for `@agentic-kit/agent`. Exposes a single hook —
|
|
16
|
+
`useChat` — that POSTs messages to a server endpoint, parses the SSE stream
|
|
17
|
+
back into typed `AgentEvent`s, and folds them into a `Message[]` plus a
|
|
18
|
+
small set of derived states (streaming snapshot, executing tools, pending
|
|
19
|
+
decisions).
|
|
20
|
+
|
|
21
|
+
The hook ships no UI. State lives in messages — there is no separate run
|
|
22
|
+
store and no `runId` to keep in sync.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @agentic-kit/react @agentic-kit/agent agentic-kit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Peer-deps `react@>=18` and `react-dom@>=18`.
|
|
31
|
+
|
|
32
|
+
## Server Contract
|
|
33
|
+
|
|
34
|
+
`useChat` POSTs JSON of the shape `{ messages, ...body() }` to `api`, and
|
|
35
|
+
expects an SSE response whose `data:` frames decode to `AgentEvent`s. The
|
|
36
|
+
easiest way to produce that is `AgentRunHandle#toResponse()`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// app/api/chat/route.ts (Next.js)
|
|
40
|
+
import { Agent } from '@agentic-kit/agent';
|
|
41
|
+
|
|
42
|
+
export async function POST(req: Request) {
|
|
43
|
+
const { messages } = await req.json();
|
|
44
|
+
const agent = new Agent({ initialState: { model, tools, systemPrompt } });
|
|
45
|
+
agent.replaceMessages(messages.slice(0, -1));
|
|
46
|
+
return agent.prompt(messages[messages.length - 1]).toResponse();
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { useChat } from '@agentic-kit/react';
|
|
54
|
+
|
|
55
|
+
export function Chat() {
|
|
56
|
+
const {
|
|
57
|
+
messages,
|
|
58
|
+
streamingMessage,
|
|
59
|
+
isStreaming,
|
|
60
|
+
pendingDecisions,
|
|
61
|
+
executingToolCallIds,
|
|
62
|
+
send,
|
|
63
|
+
respondWithDecision,
|
|
64
|
+
abort,
|
|
65
|
+
} = useChat({ api: '/api/chat' });
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div>
|
|
69
|
+
{messages.map((m, i) => <MessageView key={i} message={m} />)}
|
|
70
|
+
{streamingMessage && <MessageView message={streamingMessage} streaming />}
|
|
71
|
+
|
|
72
|
+
{[...pendingDecisions.values()].map((pending) => (
|
|
73
|
+
<DecisionPrompt
|
|
74
|
+
key={pending.toolCallId}
|
|
75
|
+
event={pending}
|
|
76
|
+
onApprove={() => respondWithDecision(pending.toolCallId, { approved: true })}
|
|
77
|
+
onDeny={() => respondWithDecision(pending.toolCallId, { approved: false })}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
|
|
81
|
+
<Composer onSubmit={send} disabled={isStreaming} onAbort={abort} />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
### `useChat(options): UseChatResult`
|
|
90
|
+
|
|
91
|
+
#### Options
|
|
92
|
+
|
|
93
|
+
- `api` — endpoint to POST messages to.
|
|
94
|
+
- `body?: () => Record<string, unknown>` — extra fields merged into each
|
|
95
|
+
request body (e.g. model id, conversation id).
|
|
96
|
+
- `initialMessages?: Message[]` — starting message log.
|
|
97
|
+
- `fetch?: typeof globalThis.fetch` — inject a custom fetch (useful for
|
|
98
|
+
auth headers, tests).
|
|
99
|
+
- `onMessage?(message)` — fired for each finalized message (assistant or
|
|
100
|
+
toolResult).
|
|
101
|
+
- `onFinish?(assistant)` — fired once per run with the final assistant
|
|
102
|
+
message.
|
|
103
|
+
- `onDecisionPending?(event)` — fired when a tool pauses awaiting a decision.
|
|
104
|
+
- `onToolExecutionStart?(event)` — fired when a tool begins running.
|
|
105
|
+
- `onToolExecutionEnd?(event)` — fired when a tool finishes (or errors).
|
|
106
|
+
- `onError?(err)` — fired on transport or server errors.
|
|
107
|
+
|
|
108
|
+
#### Result state
|
|
109
|
+
|
|
110
|
+
- `messages: Message[]` — committed log.
|
|
111
|
+
- `streamingMessage: AssistantMessage | null` — the in-flight assistant
|
|
112
|
+
message being streamed, including any partial tool-call blocks.
|
|
113
|
+
- `isStreaming: boolean`.
|
|
114
|
+
- `pendingDecisions: ReadonlyMap<string, ToolDecisionPendingEvent>` — keyed
|
|
115
|
+
by `toolCallId`.
|
|
116
|
+
- `executingToolCallIds: ReadonlySet<string>` — tool calls currently running
|
|
117
|
+
on the server.
|
|
118
|
+
- `error: unknown`.
|
|
119
|
+
|
|
120
|
+
#### Result actions
|
|
121
|
+
|
|
122
|
+
- `send(input: string | Message)` — append a user message and run.
|
|
123
|
+
- `sendMessages(msgs: Message[])` — replace the local log and run with that
|
|
124
|
+
exact list. Useful when the caller has already prepared a transcript.
|
|
125
|
+
- `setMessages(update)` — patch messages without sending. Accepts an array
|
|
126
|
+
or `(prev) => next`. Recomputes `pendingDecisions` from the new log.
|
|
127
|
+
- `respondWithDecision(toolCallId, value)` — attach a decision to the
|
|
128
|
+
matching pending `toolCall` block and re-POST. Walks the log backwards to
|
|
129
|
+
find the most recent assistant message owning that id with no decision
|
|
130
|
+
yet, so the caller is free to append unrelated messages between the pause
|
|
131
|
+
and the user's response. Throws if no pending match is found.
|
|
132
|
+
- `abort()` — cancel the in-flight request. Visible text in the streaming
|
|
133
|
+
message is preserved as a finalized assistant message; orphan tool-call
|
|
134
|
+
blocks are dropped so they don't re-pause the run on the next call.
|
|
135
|
+
|
|
136
|
+
## Patterns
|
|
137
|
+
|
|
138
|
+
### User types instead of clicking approve/deny
|
|
139
|
+
|
|
140
|
+
If the UI lets the user send a new message while a tool is paused, the
|
|
141
|
+
server can't resume on text alone — every dangling `toolCall` needs a
|
|
142
|
+
result before the next request. Compose `injectDeferralResults` from
|
|
143
|
+
`agentic-kit` with `sendMessages`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { injectDeferralResults, createUserMessage } from 'agentic-kit';
|
|
147
|
+
|
|
148
|
+
await sendMessages([
|
|
149
|
+
...injectDeferralResults(messages),
|
|
150
|
+
createUserMessage(text),
|
|
151
|
+
]);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
That synthesizes a stand-in `toolResult` for each paused tool call so the
|
|
155
|
+
transcript is well-formed when the server picks it back up.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Related Constructive Tooling
|
|
160
|
+
|
|
161
|
+
### 🤖 AI & Agent Development
|
|
162
|
+
|
|
163
|
+
* [agentic-kit](https://github.com/constructive-io/agentic-kit): **🧰 Provider-agnostic LLM adapter** with streaming and multi-turn support. Swap between Anthropic, OpenAI, Ollama, and any OpenAI-compatible endpoint.
|
|
164
|
+
|
|
165
|
+
### 📦 Package Management
|
|
166
|
+
|
|
167
|
+
* [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
|
|
168
|
+
|
|
169
|
+
### 🧪 Testing
|
|
170
|
+
|
|
171
|
+
* [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
|
|
172
|
+
* [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
173
|
+
* [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
174
|
+
|
|
175
|
+
### 🧠 Parsing & AST
|
|
176
|
+
|
|
177
|
+
* [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
|
|
178
|
+
* [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
|
|
179
|
+
* [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
|
|
180
|
+
* [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
|
|
181
|
+
|
|
182
|
+
## Credits
|
|
183
|
+
|
|
184
|
+
**🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
|
|
185
|
+
|
|
186
|
+
## Disclaimer
|
|
187
|
+
|
|
188
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
189
|
+
|
|
190
|
+
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type ToolDecisionPendingEvent, useChat, type UseChatOptions, type UseChatResult, } from './use-chat.js';
|
package/esm/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useChat, } from './use-chat.js';
|
package/esm/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AgentEvent, AgentToolResult } from '@agentic-kit/agent';
|
|
2
|
+
import type { AssistantMessage, Message } from 'agentic-kit';
|
|
3
|
+
export type ToolDecisionPendingEvent = Extract<AgentEvent, {
|
|
4
|
+
type: 'tool_decision_pending';
|
|
5
|
+
}>;
|
|
6
|
+
export interface ToolExecutionStartEvent {
|
|
7
|
+
toolCallId: string;
|
|
8
|
+
toolName: string;
|
|
9
|
+
args: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface ToolExecutionEndEvent {
|
|
12
|
+
toolCallId: string;
|
|
13
|
+
toolName: string;
|
|
14
|
+
result: AgentToolResult;
|
|
15
|
+
isError: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface UseChatOptions {
|
|
18
|
+
api: string;
|
|
19
|
+
body?: () => Record<string, unknown>;
|
|
20
|
+
initialMessages?: Message[];
|
|
21
|
+
fetch?: typeof globalThis.fetch;
|
|
22
|
+
onMessage?: (message: Message) => void;
|
|
23
|
+
onFinish?: (message: AssistantMessage) => void;
|
|
24
|
+
onDecisionPending?: (event: ToolDecisionPendingEvent) => void;
|
|
25
|
+
onToolExecutionStart?: (event: ToolExecutionStartEvent) => void;
|
|
26
|
+
onToolExecutionEnd?: (event: ToolExecutionEndEvent) => void;
|
|
27
|
+
onError?: (error: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
export interface UseChatResult {
|
|
30
|
+
messages: Message[];
|
|
31
|
+
streamingMessage: AssistantMessage | null;
|
|
32
|
+
isStreaming: boolean;
|
|
33
|
+
pendingDecisions: ReadonlyMap<string, ToolDecisionPendingEvent>;
|
|
34
|
+
executingToolCallIds: ReadonlySet<string>;
|
|
35
|
+
error: unknown;
|
|
36
|
+
send: (input: string | Message) => Promise<void>;
|
|
37
|
+
sendMessages: (messages: Message[]) => Promise<void>;
|
|
38
|
+
setMessages: (msgs: Message[] | ((prev: Message[]) => Message[])) => void;
|
|
39
|
+
respondWithDecision: (toolCallId: string, value: unknown) => Promise<void>;
|
|
40
|
+
abort: () => void;
|
|
41
|
+
}
|
|
42
|
+
export declare function useChat(options: UseChatOptions): UseChatResult;
|
package/esm/use-chat.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { parseSSEStream } from '@agentic-kit/agent';
|
|
2
|
+
import { createUserMessage } from 'agentic-kit';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Compute the pendingDecisions map from a messages array. A toolCall counts as
|
|
6
|
+
* pending when it has neither a `decision` field nor a paired `toolResult`.
|
|
7
|
+
*
|
|
8
|
+
* Used on `setMessages` to recompute server-derived state from the new array.
|
|
9
|
+
* The synthesized event is a stub (no `input`, no `schema`) — the original
|
|
10
|
+
* decision-pending event is not recoverable from messages alone, but the map
|
|
11
|
+
* key (toolCallId) is what consumers actually need to render decision UI.
|
|
12
|
+
*/
|
|
13
|
+
function rederivePendingDecisions(messages) {
|
|
14
|
+
const completed = new Set();
|
|
15
|
+
for (const m of messages) {
|
|
16
|
+
if (m.role === 'toolResult')
|
|
17
|
+
completed.add(m.toolCallId);
|
|
18
|
+
}
|
|
19
|
+
const pending = new Map();
|
|
20
|
+
for (const m of messages) {
|
|
21
|
+
if (m.role !== 'assistant')
|
|
22
|
+
continue;
|
|
23
|
+
for (const block of m.content) {
|
|
24
|
+
if (block.type !== 'toolCall')
|
|
25
|
+
continue;
|
|
26
|
+
if (completed.has(block.id))
|
|
27
|
+
continue;
|
|
28
|
+
if ('decision' in block && block.decision !== undefined)
|
|
29
|
+
continue;
|
|
30
|
+
pending.set(block.id, {
|
|
31
|
+
type: 'tool_decision_pending',
|
|
32
|
+
toolCallId: block.id,
|
|
33
|
+
toolName: block.name,
|
|
34
|
+
input: block.arguments,
|
|
35
|
+
schema: { type: 'object' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return pending;
|
|
40
|
+
}
|
|
41
|
+
export function useChat(options) {
|
|
42
|
+
const [messages, setMessagesState] = useState(() => options.initialMessages ?? []);
|
|
43
|
+
const [streamingMessage, setStreamingMessage] = useState(null);
|
|
44
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
45
|
+
const [pendingDecisions, setPendingDecisions] = useState(() => rederivePendingDecisions(options.initialMessages ?? []));
|
|
46
|
+
const [executingToolCallIds, setExecutingToolCallIds] = useState(() => new Set());
|
|
47
|
+
const [error, setError] = useState(undefined);
|
|
48
|
+
const messagesRef = useRef(messages);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
messagesRef.current = messages;
|
|
51
|
+
}, [messages]);
|
|
52
|
+
const streamingMessageRef = useRef(null);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
streamingMessageRef.current = streamingMessage;
|
|
55
|
+
}, [streamingMessage]);
|
|
56
|
+
const optionsRef = useRef(options);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
optionsRef.current = options;
|
|
59
|
+
}, [options]);
|
|
60
|
+
const runIdRef = useRef(0);
|
|
61
|
+
const abortControllerRef = useRef(null);
|
|
62
|
+
const failRun = useCallback((err) => {
|
|
63
|
+
setError(err);
|
|
64
|
+
optionsRef.current.onError?.(err);
|
|
65
|
+
setIsStreaming(false);
|
|
66
|
+
}, []);
|
|
67
|
+
const runStream = useCallback(async (requestMessages, optimisticUserMessage) => {
|
|
68
|
+
const opts = optionsRef.current;
|
|
69
|
+
const myRun = ++runIdRef.current;
|
|
70
|
+
abortControllerRef.current?.abort();
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
abortControllerRef.current = controller;
|
|
73
|
+
const isCurrent = () => runIdRef.current === myRun;
|
|
74
|
+
setIsStreaming(true);
|
|
75
|
+
setError(undefined);
|
|
76
|
+
setPendingDecisions(new Map());
|
|
77
|
+
setExecutingToolCallIds(new Set());
|
|
78
|
+
setStreamingMessage(null);
|
|
79
|
+
if (optimisticUserMessage) {
|
|
80
|
+
setMessagesState((prev) => [...prev, optimisticUserMessage]);
|
|
81
|
+
}
|
|
82
|
+
let skipUserEcho = optimisticUserMessage !== null;
|
|
83
|
+
const fetchFn = opts.fetch ?? globalThis.fetch;
|
|
84
|
+
const extraBody = opts.body?.() ?? {};
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await fetchFn(opts.api, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ messages: requestMessages, ...extraBody }),
|
|
91
|
+
signal: controller.signal,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (!isCurrent())
|
|
96
|
+
return;
|
|
97
|
+
if (controller.signal.aborted) {
|
|
98
|
+
if (isCurrent())
|
|
99
|
+
setIsStreaming(false);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
failRun(err);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!isCurrent())
|
|
106
|
+
return;
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
failRun(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!response.body) {
|
|
112
|
+
failRun(new Error('Response has no body'));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
for await (const event of parseSSEStream(response.body)) {
|
|
117
|
+
if (!isCurrent())
|
|
118
|
+
return;
|
|
119
|
+
switch (event.type) {
|
|
120
|
+
case 'message_start': {
|
|
121
|
+
if (skipUserEcho && event.message.role === 'user') {
|
|
122
|
+
skipUserEcho = false;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (event.message.role === 'assistant') {
|
|
126
|
+
setStreamingMessage(event.message);
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case 'message_update': {
|
|
131
|
+
setStreamingMessage(event.message);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'message_end': {
|
|
135
|
+
if (event.message.role === 'assistant') {
|
|
136
|
+
setStreamingMessage(null);
|
|
137
|
+
setMessagesState((prev) => [...prev, event.message]);
|
|
138
|
+
}
|
|
139
|
+
else if (event.message.role === 'toolResult') {
|
|
140
|
+
setMessagesState((prev) => [...prev, event.message]);
|
|
141
|
+
}
|
|
142
|
+
opts.onMessage?.(event.message);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'tool_execution_start': {
|
|
146
|
+
setExecutingToolCallIds((prev) => {
|
|
147
|
+
const next = new Set(prev);
|
|
148
|
+
next.add(event.toolCallId);
|
|
149
|
+
return next;
|
|
150
|
+
});
|
|
151
|
+
opts.onToolExecutionStart?.({
|
|
152
|
+
toolCallId: event.toolCallId,
|
|
153
|
+
toolName: event.toolName,
|
|
154
|
+
args: event.args,
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case 'tool_execution_end': {
|
|
159
|
+
setExecutingToolCallIds((prev) => {
|
|
160
|
+
if (!prev.has(event.toolCallId))
|
|
161
|
+
return prev;
|
|
162
|
+
const next = new Set(prev);
|
|
163
|
+
next.delete(event.toolCallId);
|
|
164
|
+
return next;
|
|
165
|
+
});
|
|
166
|
+
opts.onToolExecutionEnd?.({
|
|
167
|
+
toolCallId: event.toolCallId,
|
|
168
|
+
toolName: event.toolName,
|
|
169
|
+
result: event.result,
|
|
170
|
+
isError: event.isError,
|
|
171
|
+
});
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'tool_decision_pending': {
|
|
175
|
+
setPendingDecisions((prev) => {
|
|
176
|
+
const next = new Map(prev);
|
|
177
|
+
next.set(event.toolCallId, event);
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
opts.onDecisionPending?.(event);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 'agent_end': {
|
|
184
|
+
setStreamingMessage(null);
|
|
185
|
+
setMessagesState(() => {
|
|
186
|
+
if (!isCurrent())
|
|
187
|
+
return messagesRef.current;
|
|
188
|
+
return event.messages;
|
|
189
|
+
});
|
|
190
|
+
const lastAssistant = [...event.messages]
|
|
191
|
+
.reverse()
|
|
192
|
+
.find((m) => m.role === 'assistant');
|
|
193
|
+
if (lastAssistant) {
|
|
194
|
+
opts.onFinish?.(lastAssistant);
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (!isCurrent())
|
|
203
|
+
return;
|
|
204
|
+
if (controller.signal.aborted)
|
|
205
|
+
return;
|
|
206
|
+
failRun(err);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (isCurrent()) {
|
|
211
|
+
setIsStreaming(false);
|
|
212
|
+
abortControllerRef.current = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}, [failRun]);
|
|
216
|
+
const sendMessages = useCallback(async (msgs) => {
|
|
217
|
+
setMessagesState(msgs);
|
|
218
|
+
messagesRef.current = msgs;
|
|
219
|
+
await runStream(msgs, null);
|
|
220
|
+
}, [runStream]);
|
|
221
|
+
const send = useCallback(async (input) => {
|
|
222
|
+
const userMessage = typeof input === 'string' ? createUserMessage(input) : input;
|
|
223
|
+
const requestMessages = [...messagesRef.current, userMessage];
|
|
224
|
+
messagesRef.current = requestMessages;
|
|
225
|
+
await runStream(requestMessages, userMessage);
|
|
226
|
+
}, [runStream]);
|
|
227
|
+
const respondWithDecision = useCallback(async (toolCallId, value) => {
|
|
228
|
+
const current = messagesRef.current;
|
|
229
|
+
let targetIdx = -1;
|
|
230
|
+
for (let i = current.length - 1; i >= 0; i--) {
|
|
231
|
+
const msg = current[i];
|
|
232
|
+
if (msg.role !== 'assistant')
|
|
233
|
+
continue;
|
|
234
|
+
const match = msg.content.find((block) => block.type === 'toolCall' && block.id === toolCallId);
|
|
235
|
+
if (!match)
|
|
236
|
+
continue;
|
|
237
|
+
if ('decision' in match && match.decision !== undefined)
|
|
238
|
+
continue;
|
|
239
|
+
targetIdx = i;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (targetIdx === -1) {
|
|
243
|
+
throw new Error(`No pending decision for toolCallId '${toolCallId}'`);
|
|
244
|
+
}
|
|
245
|
+
const target = current[targetIdx];
|
|
246
|
+
const updatedAssistant = {
|
|
247
|
+
...target,
|
|
248
|
+
content: target.content.map((block) => {
|
|
249
|
+
if (block.type !== 'toolCall' || block.id !== toolCallId) {
|
|
250
|
+
return block;
|
|
251
|
+
}
|
|
252
|
+
return { ...block, decision: value };
|
|
253
|
+
}),
|
|
254
|
+
};
|
|
255
|
+
const requestMessages = [
|
|
256
|
+
...current.slice(0, targetIdx),
|
|
257
|
+
updatedAssistant,
|
|
258
|
+
...current.slice(targetIdx + 1),
|
|
259
|
+
];
|
|
260
|
+
setMessagesState(requestMessages);
|
|
261
|
+
messagesRef.current = requestMessages;
|
|
262
|
+
setPendingDecisions((prev) => {
|
|
263
|
+
if (!prev.has(toolCallId))
|
|
264
|
+
return prev;
|
|
265
|
+
const next = new Map(prev);
|
|
266
|
+
next.delete(toolCallId);
|
|
267
|
+
return next;
|
|
268
|
+
});
|
|
269
|
+
await runStream(requestMessages, null);
|
|
270
|
+
}, [runStream]);
|
|
271
|
+
const setMessages = useCallback((update) => {
|
|
272
|
+
setMessagesState((prev) => {
|
|
273
|
+
const next = typeof update === 'function' ? update(prev) : update;
|
|
274
|
+
messagesRef.current = next;
|
|
275
|
+
setPendingDecisions(rederivePendingDecisions(next));
|
|
276
|
+
setExecutingToolCallIds(new Set());
|
|
277
|
+
setError(undefined);
|
|
278
|
+
return next;
|
|
279
|
+
});
|
|
280
|
+
}, []);
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
return () => {
|
|
283
|
+
abortControllerRef.current?.abort();
|
|
284
|
+
abortControllerRef.current = null;
|
|
285
|
+
};
|
|
286
|
+
}, []);
|
|
287
|
+
const abort = useCallback(() => {
|
|
288
|
+
abortControllerRef.current?.abort();
|
|
289
|
+
abortControllerRef.current = null;
|
|
290
|
+
runIdRef.current++;
|
|
291
|
+
// Snapshot the in-flight assistant message before clearing so visible text
|
|
292
|
+
// survives the stop. Drop toolCall blocks — without results they'd surface
|
|
293
|
+
// as pending decisions via rederivePendingDecisions and re-pause the agent.
|
|
294
|
+
const partial = streamingMessageRef.current;
|
|
295
|
+
if (partial) {
|
|
296
|
+
const visible = partial.content.filter((b) => b.type === 'text' && b.text.length > 0);
|
|
297
|
+
if (visible.length > 0) {
|
|
298
|
+
setMessagesState((prev) => [...prev, { ...partial, content: visible }]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
setIsStreaming(false);
|
|
302
|
+
setStreamingMessage(null);
|
|
303
|
+
setExecutingToolCallIds(new Set());
|
|
304
|
+
setPendingDecisions(new Map());
|
|
305
|
+
}, []);
|
|
306
|
+
return useMemo(() => ({
|
|
307
|
+
messages,
|
|
308
|
+
streamingMessage,
|
|
309
|
+
isStreaming,
|
|
310
|
+
pendingDecisions,
|
|
311
|
+
executingToolCallIds,
|
|
312
|
+
error,
|
|
313
|
+
send,
|
|
314
|
+
sendMessages,
|
|
315
|
+
setMessages,
|
|
316
|
+
respondWithDecision,
|
|
317
|
+
abort,
|
|
318
|
+
}), [
|
|
319
|
+
messages,
|
|
320
|
+
streamingMessage,
|
|
321
|
+
isStreaming,
|
|
322
|
+
pendingDecisions,
|
|
323
|
+
executingToolCallIds,
|
|
324
|
+
error,
|
|
325
|
+
send,
|
|
326
|
+
sendMessages,
|
|
327
|
+
setMessages,
|
|
328
|
+
respondWithDecision,
|
|
329
|
+
abort,
|
|
330
|
+
]);
|
|
331
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type ToolDecisionPendingEvent, useChat, type UseChatOptions, type UseChatResult, } from './use-chat.js';
|
package/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useChat = void 0;
|
|
4
|
+
var use_chat_js_1 = require("./use-chat.js");
|
|
5
|
+
Object.defineProperty(exports, "useChat", { enumerable: true, get: function () { return use_chat_js_1.useChat; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentic-kit/react",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"author": "Dan Lynch <pyramation@gmail.com>",
|
|
5
|
+
"description": "React bindings for agentic-kit (useChat hook)",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"import": "./esm/index.js",
|
|
13
|
+
"require": "./index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/constructive-io/agentic-kit",
|
|
17
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"directory": "dist"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/constructive-io/agentic-kit"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/constructive-io/agentic-kit/issues"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"clean": "makage clean",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"build": "makage build",
|
|
33
|
+
"postbuild": "node ../../scripts/write-esm-package-json.js",
|
|
34
|
+
"build:dev": "makage build --dev",
|
|
35
|
+
"lint": "eslint . --fix",
|
|
36
|
+
"test": "jest",
|
|
37
|
+
"test:watch": "jest --watch"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@agentic-kit/agent": "0.3.0",
|
|
41
|
+
"agentic-kit": "1.2.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=18",
|
|
45
|
+
"react-dom": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@testing-library/dom": "10.4.1",
|
|
49
|
+
"@testing-library/react": "16.3.2",
|
|
50
|
+
"@types/react": "19.2.14",
|
|
51
|
+
"@types/react-dom": "19.2.3",
|
|
52
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
53
|
+
"react": "19.2.5",
|
|
54
|
+
"react-dom": "19.2.5"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [],
|
|
57
|
+
"gitHead": "d4f3f660be621267efe12671c22db202ccdb3f0b"
|
|
58
|
+
}
|
package/use-chat.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AgentEvent, AgentToolResult } from '@agentic-kit/agent';
|
|
2
|
+
import type { AssistantMessage, Message } from 'agentic-kit';
|
|
3
|
+
export type ToolDecisionPendingEvent = Extract<AgentEvent, {
|
|
4
|
+
type: 'tool_decision_pending';
|
|
5
|
+
}>;
|
|
6
|
+
export interface ToolExecutionStartEvent {
|
|
7
|
+
toolCallId: string;
|
|
8
|
+
toolName: string;
|
|
9
|
+
args: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface ToolExecutionEndEvent {
|
|
12
|
+
toolCallId: string;
|
|
13
|
+
toolName: string;
|
|
14
|
+
result: AgentToolResult;
|
|
15
|
+
isError: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface UseChatOptions {
|
|
18
|
+
api: string;
|
|
19
|
+
body?: () => Record<string, unknown>;
|
|
20
|
+
initialMessages?: Message[];
|
|
21
|
+
fetch?: typeof globalThis.fetch;
|
|
22
|
+
onMessage?: (message: Message) => void;
|
|
23
|
+
onFinish?: (message: AssistantMessage) => void;
|
|
24
|
+
onDecisionPending?: (event: ToolDecisionPendingEvent) => void;
|
|
25
|
+
onToolExecutionStart?: (event: ToolExecutionStartEvent) => void;
|
|
26
|
+
onToolExecutionEnd?: (event: ToolExecutionEndEvent) => void;
|
|
27
|
+
onError?: (error: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
export interface UseChatResult {
|
|
30
|
+
messages: Message[];
|
|
31
|
+
streamingMessage: AssistantMessage | null;
|
|
32
|
+
isStreaming: boolean;
|
|
33
|
+
pendingDecisions: ReadonlyMap<string, ToolDecisionPendingEvent>;
|
|
34
|
+
executingToolCallIds: ReadonlySet<string>;
|
|
35
|
+
error: unknown;
|
|
36
|
+
send: (input: string | Message) => Promise<void>;
|
|
37
|
+
sendMessages: (messages: Message[]) => Promise<void>;
|
|
38
|
+
setMessages: (msgs: Message[] | ((prev: Message[]) => Message[])) => void;
|
|
39
|
+
respondWithDecision: (toolCallId: string, value: unknown) => Promise<void>;
|
|
40
|
+
abort: () => void;
|
|
41
|
+
}
|
|
42
|
+
export declare function useChat(options: UseChatOptions): UseChatResult;
|
package/use-chat.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useChat = useChat;
|
|
4
|
+
const agent_1 = require("@agentic-kit/agent");
|
|
5
|
+
const agentic_kit_1 = require("agentic-kit");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
/**
|
|
8
|
+
* Compute the pendingDecisions map from a messages array. A toolCall counts as
|
|
9
|
+
* pending when it has neither a `decision` field nor a paired `toolResult`.
|
|
10
|
+
*
|
|
11
|
+
* Used on `setMessages` to recompute server-derived state from the new array.
|
|
12
|
+
* The synthesized event is a stub (no `input`, no `schema`) — the original
|
|
13
|
+
* decision-pending event is not recoverable from messages alone, but the map
|
|
14
|
+
* key (toolCallId) is what consumers actually need to render decision UI.
|
|
15
|
+
*/
|
|
16
|
+
function rederivePendingDecisions(messages) {
|
|
17
|
+
const completed = new Set();
|
|
18
|
+
for (const m of messages) {
|
|
19
|
+
if (m.role === 'toolResult')
|
|
20
|
+
completed.add(m.toolCallId);
|
|
21
|
+
}
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
for (const m of messages) {
|
|
24
|
+
if (m.role !== 'assistant')
|
|
25
|
+
continue;
|
|
26
|
+
for (const block of m.content) {
|
|
27
|
+
if (block.type !== 'toolCall')
|
|
28
|
+
continue;
|
|
29
|
+
if (completed.has(block.id))
|
|
30
|
+
continue;
|
|
31
|
+
if ('decision' in block && block.decision !== undefined)
|
|
32
|
+
continue;
|
|
33
|
+
pending.set(block.id, {
|
|
34
|
+
type: 'tool_decision_pending',
|
|
35
|
+
toolCallId: block.id,
|
|
36
|
+
toolName: block.name,
|
|
37
|
+
input: block.arguments,
|
|
38
|
+
schema: { type: 'object' },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return pending;
|
|
43
|
+
}
|
|
44
|
+
function useChat(options) {
|
|
45
|
+
const [messages, setMessagesState] = (0, react_1.useState)(() => options.initialMessages ?? []);
|
|
46
|
+
const [streamingMessage, setStreamingMessage] = (0, react_1.useState)(null);
|
|
47
|
+
const [isStreaming, setIsStreaming] = (0, react_1.useState)(false);
|
|
48
|
+
const [pendingDecisions, setPendingDecisions] = (0, react_1.useState)(() => rederivePendingDecisions(options.initialMessages ?? []));
|
|
49
|
+
const [executingToolCallIds, setExecutingToolCallIds] = (0, react_1.useState)(() => new Set());
|
|
50
|
+
const [error, setError] = (0, react_1.useState)(undefined);
|
|
51
|
+
const messagesRef = (0, react_1.useRef)(messages);
|
|
52
|
+
(0, react_1.useEffect)(() => {
|
|
53
|
+
messagesRef.current = messages;
|
|
54
|
+
}, [messages]);
|
|
55
|
+
const streamingMessageRef = (0, react_1.useRef)(null);
|
|
56
|
+
(0, react_1.useEffect)(() => {
|
|
57
|
+
streamingMessageRef.current = streamingMessage;
|
|
58
|
+
}, [streamingMessage]);
|
|
59
|
+
const optionsRef = (0, react_1.useRef)(options);
|
|
60
|
+
(0, react_1.useEffect)(() => {
|
|
61
|
+
optionsRef.current = options;
|
|
62
|
+
}, [options]);
|
|
63
|
+
const runIdRef = (0, react_1.useRef)(0);
|
|
64
|
+
const abortControllerRef = (0, react_1.useRef)(null);
|
|
65
|
+
const failRun = (0, react_1.useCallback)((err) => {
|
|
66
|
+
setError(err);
|
|
67
|
+
optionsRef.current.onError?.(err);
|
|
68
|
+
setIsStreaming(false);
|
|
69
|
+
}, []);
|
|
70
|
+
const runStream = (0, react_1.useCallback)(async (requestMessages, optimisticUserMessage) => {
|
|
71
|
+
const opts = optionsRef.current;
|
|
72
|
+
const myRun = ++runIdRef.current;
|
|
73
|
+
abortControllerRef.current?.abort();
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
abortControllerRef.current = controller;
|
|
76
|
+
const isCurrent = () => runIdRef.current === myRun;
|
|
77
|
+
setIsStreaming(true);
|
|
78
|
+
setError(undefined);
|
|
79
|
+
setPendingDecisions(new Map());
|
|
80
|
+
setExecutingToolCallIds(new Set());
|
|
81
|
+
setStreamingMessage(null);
|
|
82
|
+
if (optimisticUserMessage) {
|
|
83
|
+
setMessagesState((prev) => [...prev, optimisticUserMessage]);
|
|
84
|
+
}
|
|
85
|
+
let skipUserEcho = optimisticUserMessage !== null;
|
|
86
|
+
const fetchFn = opts.fetch ?? globalThis.fetch;
|
|
87
|
+
const extraBody = opts.body?.() ?? {};
|
|
88
|
+
let response;
|
|
89
|
+
try {
|
|
90
|
+
response = await fetchFn(opts.api, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ messages: requestMessages, ...extraBody }),
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (!isCurrent())
|
|
99
|
+
return;
|
|
100
|
+
if (controller.signal.aborted) {
|
|
101
|
+
if (isCurrent())
|
|
102
|
+
setIsStreaming(false);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
failRun(err);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!isCurrent())
|
|
109
|
+
return;
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
failRun(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!response.body) {
|
|
115
|
+
failRun(new Error('Response has no body'));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
for await (const event of (0, agent_1.parseSSEStream)(response.body)) {
|
|
120
|
+
if (!isCurrent())
|
|
121
|
+
return;
|
|
122
|
+
switch (event.type) {
|
|
123
|
+
case 'message_start': {
|
|
124
|
+
if (skipUserEcho && event.message.role === 'user') {
|
|
125
|
+
skipUserEcho = false;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (event.message.role === 'assistant') {
|
|
129
|
+
setStreamingMessage(event.message);
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'message_update': {
|
|
134
|
+
setStreamingMessage(event.message);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'message_end': {
|
|
138
|
+
if (event.message.role === 'assistant') {
|
|
139
|
+
setStreamingMessage(null);
|
|
140
|
+
setMessagesState((prev) => [...prev, event.message]);
|
|
141
|
+
}
|
|
142
|
+
else if (event.message.role === 'toolResult') {
|
|
143
|
+
setMessagesState((prev) => [...prev, event.message]);
|
|
144
|
+
}
|
|
145
|
+
opts.onMessage?.(event.message);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'tool_execution_start': {
|
|
149
|
+
setExecutingToolCallIds((prev) => {
|
|
150
|
+
const next = new Set(prev);
|
|
151
|
+
next.add(event.toolCallId);
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
opts.onToolExecutionStart?.({
|
|
155
|
+
toolCallId: event.toolCallId,
|
|
156
|
+
toolName: event.toolName,
|
|
157
|
+
args: event.args,
|
|
158
|
+
});
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'tool_execution_end': {
|
|
162
|
+
setExecutingToolCallIds((prev) => {
|
|
163
|
+
if (!prev.has(event.toolCallId))
|
|
164
|
+
return prev;
|
|
165
|
+
const next = new Set(prev);
|
|
166
|
+
next.delete(event.toolCallId);
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
opts.onToolExecutionEnd?.({
|
|
170
|
+
toolCallId: event.toolCallId,
|
|
171
|
+
toolName: event.toolName,
|
|
172
|
+
result: event.result,
|
|
173
|
+
isError: event.isError,
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'tool_decision_pending': {
|
|
178
|
+
setPendingDecisions((prev) => {
|
|
179
|
+
const next = new Map(prev);
|
|
180
|
+
next.set(event.toolCallId, event);
|
|
181
|
+
return next;
|
|
182
|
+
});
|
|
183
|
+
opts.onDecisionPending?.(event);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'agent_end': {
|
|
187
|
+
setStreamingMessage(null);
|
|
188
|
+
setMessagesState(() => {
|
|
189
|
+
if (!isCurrent())
|
|
190
|
+
return messagesRef.current;
|
|
191
|
+
return event.messages;
|
|
192
|
+
});
|
|
193
|
+
const lastAssistant = [...event.messages]
|
|
194
|
+
.reverse()
|
|
195
|
+
.find((m) => m.role === 'assistant');
|
|
196
|
+
if (lastAssistant) {
|
|
197
|
+
opts.onFinish?.(lastAssistant);
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (!isCurrent())
|
|
206
|
+
return;
|
|
207
|
+
if (controller.signal.aborted)
|
|
208
|
+
return;
|
|
209
|
+
failRun(err);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
if (isCurrent()) {
|
|
214
|
+
setIsStreaming(false);
|
|
215
|
+
abortControllerRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}, [failRun]);
|
|
219
|
+
const sendMessages = (0, react_1.useCallback)(async (msgs) => {
|
|
220
|
+
setMessagesState(msgs);
|
|
221
|
+
messagesRef.current = msgs;
|
|
222
|
+
await runStream(msgs, null);
|
|
223
|
+
}, [runStream]);
|
|
224
|
+
const send = (0, react_1.useCallback)(async (input) => {
|
|
225
|
+
const userMessage = typeof input === 'string' ? (0, agentic_kit_1.createUserMessage)(input) : input;
|
|
226
|
+
const requestMessages = [...messagesRef.current, userMessage];
|
|
227
|
+
messagesRef.current = requestMessages;
|
|
228
|
+
await runStream(requestMessages, userMessage);
|
|
229
|
+
}, [runStream]);
|
|
230
|
+
const respondWithDecision = (0, react_1.useCallback)(async (toolCallId, value) => {
|
|
231
|
+
const current = messagesRef.current;
|
|
232
|
+
let targetIdx = -1;
|
|
233
|
+
for (let i = current.length - 1; i >= 0; i--) {
|
|
234
|
+
const msg = current[i];
|
|
235
|
+
if (msg.role !== 'assistant')
|
|
236
|
+
continue;
|
|
237
|
+
const match = msg.content.find((block) => block.type === 'toolCall' && block.id === toolCallId);
|
|
238
|
+
if (!match)
|
|
239
|
+
continue;
|
|
240
|
+
if ('decision' in match && match.decision !== undefined)
|
|
241
|
+
continue;
|
|
242
|
+
targetIdx = i;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (targetIdx === -1) {
|
|
246
|
+
throw new Error(`No pending decision for toolCallId '${toolCallId}'`);
|
|
247
|
+
}
|
|
248
|
+
const target = current[targetIdx];
|
|
249
|
+
const updatedAssistant = {
|
|
250
|
+
...target,
|
|
251
|
+
content: target.content.map((block) => {
|
|
252
|
+
if (block.type !== 'toolCall' || block.id !== toolCallId) {
|
|
253
|
+
return block;
|
|
254
|
+
}
|
|
255
|
+
return { ...block, decision: value };
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
const requestMessages = [
|
|
259
|
+
...current.slice(0, targetIdx),
|
|
260
|
+
updatedAssistant,
|
|
261
|
+
...current.slice(targetIdx + 1),
|
|
262
|
+
];
|
|
263
|
+
setMessagesState(requestMessages);
|
|
264
|
+
messagesRef.current = requestMessages;
|
|
265
|
+
setPendingDecisions((prev) => {
|
|
266
|
+
if (!prev.has(toolCallId))
|
|
267
|
+
return prev;
|
|
268
|
+
const next = new Map(prev);
|
|
269
|
+
next.delete(toolCallId);
|
|
270
|
+
return next;
|
|
271
|
+
});
|
|
272
|
+
await runStream(requestMessages, null);
|
|
273
|
+
}, [runStream]);
|
|
274
|
+
const setMessages = (0, react_1.useCallback)((update) => {
|
|
275
|
+
setMessagesState((prev) => {
|
|
276
|
+
const next = typeof update === 'function' ? update(prev) : update;
|
|
277
|
+
messagesRef.current = next;
|
|
278
|
+
setPendingDecisions(rederivePendingDecisions(next));
|
|
279
|
+
setExecutingToolCallIds(new Set());
|
|
280
|
+
setError(undefined);
|
|
281
|
+
return next;
|
|
282
|
+
});
|
|
283
|
+
}, []);
|
|
284
|
+
(0, react_1.useEffect)(() => {
|
|
285
|
+
return () => {
|
|
286
|
+
abortControllerRef.current?.abort();
|
|
287
|
+
abortControllerRef.current = null;
|
|
288
|
+
};
|
|
289
|
+
}, []);
|
|
290
|
+
const abort = (0, react_1.useCallback)(() => {
|
|
291
|
+
abortControllerRef.current?.abort();
|
|
292
|
+
abortControllerRef.current = null;
|
|
293
|
+
runIdRef.current++;
|
|
294
|
+
// Snapshot the in-flight assistant message before clearing so visible text
|
|
295
|
+
// survives the stop. Drop toolCall blocks — without results they'd surface
|
|
296
|
+
// as pending decisions via rederivePendingDecisions and re-pause the agent.
|
|
297
|
+
const partial = streamingMessageRef.current;
|
|
298
|
+
if (partial) {
|
|
299
|
+
const visible = partial.content.filter((b) => b.type === 'text' && b.text.length > 0);
|
|
300
|
+
if (visible.length > 0) {
|
|
301
|
+
setMessagesState((prev) => [...prev, { ...partial, content: visible }]);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
setIsStreaming(false);
|
|
305
|
+
setStreamingMessage(null);
|
|
306
|
+
setExecutingToolCallIds(new Set());
|
|
307
|
+
setPendingDecisions(new Map());
|
|
308
|
+
}, []);
|
|
309
|
+
return (0, react_1.useMemo)(() => ({
|
|
310
|
+
messages,
|
|
311
|
+
streamingMessage,
|
|
312
|
+
isStreaming,
|
|
313
|
+
pendingDecisions,
|
|
314
|
+
executingToolCallIds,
|
|
315
|
+
error,
|
|
316
|
+
send,
|
|
317
|
+
sendMessages,
|
|
318
|
+
setMessages,
|
|
319
|
+
respondWithDecision,
|
|
320
|
+
abort,
|
|
321
|
+
}), [
|
|
322
|
+
messages,
|
|
323
|
+
streamingMessage,
|
|
324
|
+
isStreaming,
|
|
325
|
+
pendingDecisions,
|
|
326
|
+
executingToolCallIds,
|
|
327
|
+
error,
|
|
328
|
+
send,
|
|
329
|
+
sendMessages,
|
|
330
|
+
setMessages,
|
|
331
|
+
respondWithDecision,
|
|
332
|
+
abort,
|
|
333
|
+
]);
|
|
334
|
+
}
|