akanjs 3.0.0-alpha.55 → 3.0.0-alpha.57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.55",
3
+ "version": "3.0.0-alpha.57",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -7,15 +7,18 @@ import type {
7
7
  } from "./predefinedAdaptor/llm.adaptor";
8
8
  import { LlmAdaptorRole } from "./predefinedAdaptor/role.adaptor";
9
9
  import { serve } from "./serve";
10
+ import { ToolNames } from "./toolNames";
10
11
 
11
12
  export class AgentService extends serve("agent" as const, ({ plug }) => ({
12
13
  llm: plug(LlmAdaptorRole),
13
14
  })) {
14
15
  async runTurn(request: LlmTurnRequest, onDelta?: (delta: string) => void) {
15
- const prepared = AgentService.readable(AgentService.explained(request), this.llm.accepts);
16
+
17
+ const names = ToolNames.of(request);
18
+ const prepared = names.encode(AgentService.readable(AgentService.explained(request), this.llm.accepts));
16
19
  const answer = await this.llm.chat(prepared, onDelta);
17
20
  if (!answer) throw new Err("agent.error.llmUnavailable");
18
- return { text: answer.text ?? "", toolCalls: answer.toolCalls ?? [], stop: answer.stop };
21
+ return { text: answer.text ?? "", toolCalls: names.decode(answer.toolCalls ?? []), stop: answer.stop };
19
22
  }
20
23
 
21
24
  /**
@@ -0,0 +1,119 @@
1
+ import type { AgentWireMessage, AgentWireToolCall, LlmTurnRequest } from "./predefinedAdaptor/llm.adaptor";
2
+
3
+ const wireSafe = /^[A-Za-z0-9_-]+$/;
4
+
5
+ /**
6
+ * Renames a turn's tools onto what a provider's function-calling wire accepts, and reads the answer back.
7
+ *
8
+ * A zone publishes its tools scope-prefixed — `videoProjectDraft.createVideoProject` — which is legal for MCP,
9
+ * where `.` is an allowed character and the scope join. Every OpenAI-compatible and Anthropic function schema is
10
+ * narrower: `[A-Za-z0-9_-]`, at most 64 characters. A provider that validates answers 400; DeepSeek does not, and
11
+ * what happened instead was worse to debug — the model normalized the illegal name itself, called the bare
12
+ * `createVideoProject`, and the browser answered `Unknown tool`, spending a turn on a tool that was published all
13
+ * along.
14
+ *
15
+ * Renamed here rather than in each adaptor for the reason `AgentService.explained` gives: every adaptor would
16
+ * otherwise have to remember, and forgetting is silent. A name the wire already accepts is left alone, so the
17
+ * root agent's request is byte-for-byte what it was.
18
+ */
19
+ export class ToolNames {
20
+ /** Both dialects reject a longer name, and neither says so in terms of the tool you wrote. */
21
+ static readonly limit = 64;
22
+
23
+ readonly #toWire = new Map<string, string>();
24
+ readonly #toSurface = new Map<string, string>();
25
+
26
+ /**
27
+ * Every name the request carries, not just the published ones: the transcript holds calls to tools that have
28
+ * since left the screen, and one of those reaching the wire unrenamed is the same failure a turn later.
29
+ */
30
+ static of(request: LlmTurnRequest): ToolNames {
31
+ return new ToolNames([
32
+ ...request.tools.map((tool) => tool.name),
33
+ ...request.messages.flatMap((message) => ToolNames.#namesIn(message)),
34
+ ]);
35
+ }
36
+
37
+ constructor(names: Iterable<string>) {
38
+ const all = [...new Set(names)];
39
+
40
+ const taken = new Set(all.filter((name) => ToolNames.#fits(name)));
41
+
42
+ for (const name of all.filter((candidate) => !ToolNames.#fits(candidate)).sort((a, b) => (a < b ? -1 : 1))) {
43
+ const wire = ToolNames.#unique(ToolNames.#fold(name), taken);
44
+ taken.add(wire);
45
+ this.#toWire.set(name, wire);
46
+ this.#toSurface.set(wire, name);
47
+ }
48
+ }
49
+
50
+ get renamed() {
51
+ return this.#toWire.size > 0;
52
+ }
53
+
54
+ wire(name: string) {
55
+ return this.#toWire.get(name) ?? name;
56
+ }
57
+
58
+ /**
59
+ * Unknown stays as it came. A model that invented a name is answered by the surface's own `Unknown tool`, which
60
+ * lands in the transcript as a tool result it can correct from — guessing which tool it meant would run one.
61
+ */
62
+ surface(name: string) {
63
+ return this.#toSurface.get(name) ?? name;
64
+ }
65
+
66
+ encode(request: LlmTurnRequest): LlmTurnRequest {
67
+ if (!this.renamed) return request;
68
+ return {
69
+ ...request,
70
+ tools: request.tools.map((tool) => ({ ...tool, name: this.wire(tool.name) })),
71
+ messages: request.messages.map((message) => this.#encoded(message)),
72
+ };
73
+ }
74
+
75
+ decode(calls: AgentWireToolCall[]): AgentWireToolCall[] {
76
+ if (!this.renamed) return calls;
77
+ return calls.map((call) => ({ ...call, name: this.surface(call.name) }));
78
+ }
79
+
80
+ #encoded(message: AgentWireMessage): AgentWireMessage {
81
+ if (!message.toolCalls?.length && !message.toolResults?.length) return message;
82
+ return {
83
+ ...message,
84
+ ...(message.toolCalls?.length
85
+ ? { toolCalls: message.toolCalls.map((call) => ({ ...call, name: this.wire(call.name) })) }
86
+ : {}),
87
+ ...(message.toolResults?.length
88
+ ? { toolResults: message.toolResults.map((result) => ({ ...result, name: this.wire(result.name) })) }
89
+ : {}),
90
+ };
91
+ }
92
+
93
+ static #namesIn(message: AgentWireMessage): string[] {
94
+ return [
95
+ ...(message.toolCalls ?? []).map((call) => call.name),
96
+ ...(message.toolResults ?? []).map((result) => result.name),
97
+ ];
98
+ }
99
+
100
+ static #fits(name: string) {
101
+ return name.length <= ToolNames.limit && wireSafe.test(name);
102
+ }
103
+
104
+ /** `.` is the one character the surface itself adds, so it folds to the `__` every MCP client already reads. */
105
+ static #fold(name: string) {
106
+ const folded = name.replaceAll(".", "__").replace(/[^A-Za-z0-9_-]/g, "-");
107
+
108
+ return folded.length <= ToolNames.limit ? folded : folded.slice(folded.length - ToolNames.limit);
109
+ }
110
+
111
+ static #unique(candidate: string, taken: Set<string>) {
112
+ if (!taken.has(candidate)) return candidate;
113
+ for (let idx = 2; ; idx += 1) {
114
+ const suffix = `_${idx}`;
115
+ const next = `${candidate.slice(0, ToolNames.limit - suffix.length)}${suffix}`;
116
+ if (!taken.has(next)) return next;
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,35 @@
1
+ import type { AgentWireToolCall, LlmTurnRequest } from "./predefinedAdaptor/llm.adaptor";
2
+ /**
3
+ * Renames a turn's tools onto what a provider's function-calling wire accepts, and reads the answer back.
4
+ *
5
+ * A zone publishes its tools scope-prefixed — `videoProjectDraft.createVideoProject` — which is legal for MCP,
6
+ * where `.` is an allowed character and the scope join. Every OpenAI-compatible and Anthropic function schema is
7
+ * narrower: `[A-Za-z0-9_-]`, at most 64 characters. A provider that validates answers 400; DeepSeek does not, and
8
+ * what happened instead was worse to debug — the model normalized the illegal name itself, called the bare
9
+ * `createVideoProject`, and the browser answered `Unknown tool`, spending a turn on a tool that was published all
10
+ * along.
11
+ *
12
+ * Renamed here rather than in each adaptor for the reason `AgentService.explained` gives: every adaptor would
13
+ * otherwise have to remember, and forgetting is silent. A name the wire already accepts is left alone, so the
14
+ * root agent's request is byte-for-byte what it was.
15
+ */
16
+ export declare class ToolNames {
17
+ #private;
18
+ /** Both dialects reject a longer name, and neither says so in terms of the tool you wrote. */
19
+ static readonly limit = 64;
20
+ /**
21
+ * Every name the request carries, not just the published ones: the transcript holds calls to tools that have
22
+ * since left the screen, and one of those reaching the wire unrenamed is the same failure a turn later.
23
+ */
24
+ static of(request: LlmTurnRequest): ToolNames;
25
+ constructor(names: Iterable<string>);
26
+ get renamed(): boolean;
27
+ wire(name: string): string;
28
+ /**
29
+ * Unknown stays as it came. A model that invented a name is answered by the surface's own `Unknown tool`, which
30
+ * lands in the transcript as a tool result it can correct from — guessing which tool it meant would run one.
31
+ */
32
+ surface(name: string): string;
33
+ encode(request: LlmTurnRequest): LlmTurnRequest;
34
+ decode(calls: AgentWireToolCall[]): AgentWireToolCall[];
35
+ }
@@ -31,6 +31,11 @@ export interface ChatProps {
31
31
  * button, a menu item — instead of the built-in launcher. Left off, the panel owns the state as before.
32
32
  */
33
33
  open?: boolean;
34
+ /**
35
+ * Left off while `open` is controlled, the panel cannot close itself — so it draws **no close button** rather
36
+ * than an inert one. That is the shape of a fixed panel with nowhere to close to, and it is also what keeps a
37
+ * controlled chat assemblable by a server component, since this is the only prop here that is a function.
38
+ */
34
39
  onOpenChange?: (open: boolean) => void;
35
40
  /** `false` draws no launcher, for an app that opens the panel from a control of its own. */
36
41
  launcher?: boolean;
@@ -2,8 +2,12 @@ interface ContextProps {
2
2
  className?: string;
3
3
  }
4
4
  /**
5
- * Assembles and shows the exact context blocks a turn would carry, on demand — the one preview of "what does the
6
- * agent see on this screen" that no amount of reading the source answers.
5
+ * Assembles and shows exactly what a turn would carry, on demand — the one preview of "what does the agent see on
6
+ * this screen" that no amount of reading the source answers.
7
+ *
8
+ * The tool list leads, by name only: a zone publishes its tools scope-prefixed, and instructions that name a tool
9
+ * without its prefix name a tool that does not exist. That is invisible in the source of either file and obvious
10
+ * here.
7
11
  */
8
12
  export default function Context({ className }: ContextProps): import("react/jsx-runtime").JSX.Element;
9
13
  export {};
@@ -0,0 +1,20 @@
1
+ import { type AgentSessionOptions, type SessionHistory } from "../../vendor/use-agentic.d.ts";
2
+ export interface HistoryProps {
3
+ load: SessionHistory["load"];
4
+ save: SessionHistory["save"];
5
+ clear: SessionHistory["clear"];
6
+ /** Where a host with its own server-side summary moves its watermark — see `onCompact` on the session options. */
7
+ onCompact?: AgentSessionOptions["onCompact"];
8
+ }
9
+ /**
10
+ * Puts the enclosing zone's transcript wherever the app keeps it, as a mounted component rather than a prop.
11
+ *
12
+ * `persist` does the same thing and has to be passed to whoever builds the session, which makes every ancestor up
13
+ * to that point a client component — a function cannot cross the server/client boundary as a prop. Mounted here
14
+ * instead, the only client module an app needs is this leaf, and `Agent.Zone` and the chat inside it can be
15
+ * assembled by a server component. Same shape as `Agent.Guide`, and it renders nothing.
16
+ *
17
+ * Restoring follows the session's one rule: it lands only while nothing has happened to the conversation yet, so
18
+ * mounting with the zone restores and mounting later saves from there on.
19
+ */
20
+ export declare const History: ({ load, save, clear, onCompact }: HistoryProps) => null;
@@ -40,5 +40,9 @@ export interface ZoneProps {
40
40
  * inside — hook tools, `st.use` subscriptions, guides — belongs to this zone's session *and* to the root agent:
41
41
  * zones are views, never walls. An `Agent.Chat` mounted inside binds to this session automatically, so two zones
42
42
  * on one screen run two conversations in parallel, each seeing only its own subtree.
43
+ *
44
+ * **Everything a zone publishes is named `<id>.<name>`.** Instructions that name a tool must carry the prefix —
45
+ * a bare name is a tool that does not exist, and the model calling it spends a turn on `Unknown tool`. Build the
46
+ * name from the id rather than writing it twice, and read `Agent.Context`'s Assemble to see the published list.
43
47
  */
44
48
  export declare const Zone: ({ className, id, label, instructions, runner, maxTurns, compact, builtins, persist, onCompact, session: provided, onSession, children, }: ZoneProps) => import("react/jsx-runtime").JSX.Element;
@@ -8,6 +8,7 @@ export declare const Agent: {
8
8
  Context: typeof Context;
9
9
  Dock: ({ className, bridge, surface, open }: import("./Dock.d.ts").DockProps) => import("react/jsx-runtime").JSX.Element;
10
10
  Guide: ({ instructions }: import("./Guide.d.ts").GuideProps) => null;
11
+ History: ({ load, save, clear, onCompact }: import("./History.d.ts").HistoryProps) => null;
11
12
  Scope: ({ id, label, kind, children }: import("../../vendor/use-agentic.d.ts").AgentScopeProps) => import("react/jsx-runtime").JSX.Element;
12
13
  Section: typeof Section;
13
14
  Skip: ({ className, label, children }: import("./Skip.d.ts").SkipProps) => import("react/jsx-runtime").JSX.Element;
@@ -33,7 +33,7 @@ export declare const Field: {
33
33
  Price: import("react").MemoExoticComponent<({ label, desc, labelClassName, className, value, onChange, placeholder, nullable, disabled, minlength, maxlength, transform, validate, onPressEnter, inputClassName, inputStyleType, }: PriceProps) => import("react/jsx-runtime").JSX.Element>;
34
34
  TextArea: import("react").MemoExoticComponent<({ label, desc, labelClassName, className, value, onChange, placeholder, nullable, disabled, rows, minlength, maxlength, transform, validate, onPressEnter, cache, inputClassName, }: TextAreaProps) => import("react/jsx-runtime").JSX.Element>;
35
35
  Switch: ({ label, desc, labelClassName, className, value, onChange, disabled, inputClassName, onDesc, offDesc, }: SwitchProps) => import("react/jsx-runtime").JSX.Element;
36
- ToggleSelect: <I extends string | number | boolean | null>({ className, labelClassName, label, desc, items, value, validate, onChange, nullable, disabled, btnClassName, }: ToggleSelectProps<I>) => import("react/jsx-runtime").JSX.Element;
36
+ ToggleSelect: <I extends string | number | boolean | null, Nullable extends boolean = false>({ className, labelClassName, label, desc, items, value, validate, onChange, nullable, disabled, btnClassName, }: ToggleSelectProps<I, Nullable>) => import("react/jsx-runtime").JSX.Element;
37
37
  MultiToggleSelect: <I extends string | number | boolean>({ className, labelClassName, label, desc, items, value, minlength, maxlength, validate, onChange, disabled, }: MultiToggleSelectProps<I>) => import("react/jsx-runtime").JSX.Element;
38
38
  TextList: ({ label, desc, labelClassName, className, value, onChange, placeholder, disabled, transform, minlength, maxlength, minTextlength, maxTextlength, cache, validate, inputClassName, }: TextListProps) => import("react/jsx-runtime").JSX.Element;
39
39
  Tags: ({ label, desc, labelClassName, className, value, onChange, placeholder, disabled, transform, minlength, maxlength, minTextlength, maxTextlength, validate, inputClassName, }: TagsProps) => import("react/jsx-runtime").JSX.Element;
@@ -71,7 +71,7 @@ interface ListProps<Item> {
71
71
  label?: string;
72
72
  desc?: string;
73
73
  nullable?: boolean;
74
- value: Item[];
74
+ value: Item[] | null;
75
75
  onChange: (value: Item[]) => void;
76
76
  onAdd: () => void;
77
77
  renderItem: (item: Item, idx: number) => ReactNode;
@@ -137,14 +137,14 @@ interface SwitchProps {
137
137
  desc?: string;
138
138
  labelClassName?: string;
139
139
  className?: string;
140
- value: boolean;
140
+ value: boolean | null;
141
141
  onChange: (value: boolean) => void;
142
142
  inputClassName?: string;
143
143
  onDesc?: string;
144
144
  offDesc?: string;
145
145
  disabled?: boolean;
146
146
  }
147
- interface ToggleSelectProps<I> {
147
+ interface ToggleSelectProps<I, Nullable extends boolean> {
148
148
  className?: string;
149
149
  labelClassName?: string;
150
150
  label?: string;
@@ -156,11 +156,11 @@ interface ToggleSelectProps<I> {
156
156
  value: I;
157
157
  disabled?: boolean;
158
158
  }[] | readonly I[] | I[] | EnumInstance<string, I>;
159
- value: I;
160
- nullable?: boolean;
159
+ value: I | null;
160
+ nullable?: Nullable;
161
161
  disabled?: boolean;
162
162
  validate?: (value: I) => boolean | string;
163
- onChange: (value: I) => void;
163
+ onChange: (value: Nullable extends true ? I | null : I) => void;
164
164
  btnClassName?: string;
165
165
  }
166
166
  interface MultiToggleSelectProps<I extends string | number | boolean> {
@@ -173,7 +173,7 @@ interface MultiToggleSelectProps<I extends string | number | boolean> {
173
173
  value: I;
174
174
  disabled?: boolean;
175
175
  }[] | readonly I[] | I[];
176
- value: I[];
176
+ value: I[] | null;
177
177
  disabled?: boolean;
178
178
  minlength?: number;
179
179
  maxlength?: number;
@@ -185,7 +185,7 @@ interface TextListProps {
185
185
  desc?: string;
186
186
  labelClassName?: string;
187
187
  className?: string;
188
- value: string[];
188
+ value: string[] | null;
189
189
  onChange: (value: string[]) => void;
190
190
  inputClassName?: string;
191
191
  placeholder?: string;
@@ -203,7 +203,7 @@ interface TagsProps {
203
203
  desc?: string;
204
204
  labelClassName?: string;
205
205
  className?: string;
206
- value: string[];
206
+ value: string[] | null;
207
207
  onChange: (value: string[]) => void;
208
208
  inputClassName?: string;
209
209
  placeholder?: string;
@@ -386,7 +386,7 @@ interface ChildrenProps<T extends string, State, Input, Full, Light> {
386
386
  disabled?: boolean;
387
387
  nullable?: boolean;
388
388
  initArgs?: any[];
389
- value: Light[];
389
+ value: Light[] | null;
390
390
  onChange: (value: Light[]) => void;
391
391
  onSearch?: (text: string) => void;
392
392
  slice: SliceMeta;
@@ -402,7 +402,7 @@ interface ChildrenIdProps<T extends string, State, Input, Full, Light> {
402
402
  disabled?: boolean;
403
403
  nullable?: boolean;
404
404
  initArgs?: any[];
405
- value: string[];
405
+ value: string[] | null;
406
406
  slice: SliceMeta;
407
407
  onChange: (value: string[]) => void;
408
408
  onSearch?: (text: string) => void;
@@ -11,6 +11,7 @@ export interface ToggleSelectProps<I extends string | number | boolean | null> {
11
11
  nullable: boolean;
12
12
  validate: (value: I) => boolean | string;
13
13
  onChange: (value: I, idx: number) => void;
14
+ onClear?: () => void;
14
15
  disabled?: boolean;
15
16
  }
16
17
  export interface MultiProps {
@@ -8,6 +8,7 @@ export type { ChatProps } from "./Agent/Chat.d.ts";
8
8
  export { type ChatCommand, ChatCommands } from "./Agent/ChatCommands.d.ts";
9
9
  export { type ComposerProps, DefaultComposer } from "./Agent/Composer.d.ts";
10
10
  export { fetchRunner } from "./Agent/fetchRunner.d.ts";
11
+ export type { HistoryProps as AgentHistoryProps } from "./Agent/History.d.ts";
11
12
  export { DefaultLauncher, type LauncherProps } from "./Agent/Launcher.d.ts";
12
13
  export { type CodeProps, DefaultCode, DefaultMarkdown, type MarkdownProps } from "./Agent/Markdown.d.ts";
13
14
  export { DefaultMenu, type MenuProps as AgentMenuProps, type MenuRow } from "./Agent/Menu.d.ts";
@@ -127,6 +127,17 @@ export declare class AgentSession {
127
127
  * is one the winding-down turn appends onto. The returned promise waits for the history to clear too.
128
128
  */
129
129
  reset: () => Promise<void>;
130
+ /**
131
+ * Attaches a transcript store to a session built without one — what `Agent.History` mounts, so a zone can be
132
+ * assembled by a server component and still keep its transcript wherever the app keeps it. `null` detaches.
133
+ *
134
+ * Restoring follows the rule an async `load` already follows: it lands only while nothing has happened to this
135
+ * session yet. Attach before the first turn and it restores; attach after and it saves from there on, with the
136
+ * store never asked for a transcript that would be discarded. One rule rather than a mount-order surprise.
137
+ */
138
+ setHistory: (history: SessionHistory | null) => void;
139
+ /** The compaction hook as a setter, for the same reason `setHistory` is one: a host attaches it after the fact. */
140
+ setOnCompact: (onCompact: AgentSessionOptions["onCompact"] | null) => void;
130
141
  /**
131
142
  * Re-runs the last user message, dropping what the previous attempt produced. Turns fail for reasons that have
132
143
  * nothing to do with what was asked — a refused relay, a model that is unavailable — and retyping is otherwise the
@@ -7,6 +7,7 @@ export * from "./AgentScope.d.ts";
7
7
  export * from "./AgentSession.d.ts";
8
8
  export * from "./Compaction.d.ts";
9
9
  export * from "./httpRunner.d.ts";
10
+ export * from "./sharedContext.d.ts";
10
11
  export * from "./surfaceContext.d.ts";
11
12
  export * from "./ToolOutput.d.ts";
12
13
  export * from "./Transcript.d.ts";
@@ -0,0 +1,15 @@
1
+ import { type Context } from "react";
2
+ /**
3
+ * A React context interned on `globalThis`, for the reason `AgenticSurface.shared` is: an app does not get one
4
+ * copy of this package. An akan build inlines it into every client bundle that reaches it — four of them in this
5
+ * repo's own docs app — and a context is identified by object identity, so a `ScopeContext.Provider` rendered by
6
+ * `Agent.Zone` in one copy is invisible to the `st.tool` reading it from another.
7
+ *
8
+ * That failure is silent, which is what makes it worth a global: the read falls back to the context's default, so
9
+ * the tool registers at the root scope instead of the zone's, the zone session filters it out as belonging to a
10
+ * different view, and the model is handed the built-ins with nothing thrown and nothing logged.
11
+ *
12
+ * Every context this package owns goes through here. Adding one the plain way would work in the monorepo — one
13
+ * copy, one object — and fail only once bundled, which is exactly the bug this replaced.
14
+ */
15
+ export declare const sharedContext: <T>(name: string, initial: T) => Context<T>;
package/ui/Agent/Chat.tsx CHANGED
@@ -69,6 +69,11 @@ export interface ChatProps {
69
69
  * button, a menu item — instead of the built-in launcher. Left off, the panel owns the state as before.
70
70
  */
71
71
  open?: boolean;
72
+ /**
73
+ * Left off while `open` is controlled, the panel cannot close itself — so it draws **no close button** rather
74
+ * than an inert one. That is the shape of a fixed panel with nowhere to close to, and it is also what keeps a
75
+ * controlled chat assemblable by a server component, since this is the only prop here that is a function.
76
+ */
72
77
  onOpenChange?: (open: boolean) => void;
73
78
  /** `false` draws no launcher, for an app that opens the panel from a control of its own. */
74
79
  launcher?: boolean;
@@ -9,16 +9,25 @@ interface ContextProps {
9
9
  }
10
10
 
11
11
  /**
12
- * Assembles and shows the exact context blocks a turn would carry, on demand — the one preview of "what does the
13
- * agent see on this screen" that no amount of reading the source answers.
12
+ * Assembles and shows exactly what a turn would carry, on demand — the one preview of "what does the agent see on
13
+ * this screen" that no amount of reading the source answers.
14
+ *
15
+ * The tool list leads, by name only: a zone publishes its tools scope-prefixed, and instructions that name a tool
16
+ * without its prefix name a tool that does not exist. That is invisible in the source of either file and obvious
17
+ * here.
14
18
  */
15
19
  export default function Context({ className }: ContextProps) {
16
20
  const [shown, setShown] = useState("");
17
21
  const assemble = () => {
18
22
  try {
19
- const { guides } = AgenticSurface.shared.snapshot();
23
+ const { guides, tools } = AgenticSurface.shared.snapshot();
20
24
  const context = AgentContext.of().blocks(AgenticSurface.shared);
21
- setShown(JSON.stringify(guides.length ? { guides, context } : context, null, 2));
25
+ const assembled = {
26
+ tools: tools.map((tool) => tool.name),
27
+ ...(guides.length ? { guides } : {}),
28
+ context,
29
+ };
30
+ setShown(JSON.stringify(assembled, null, 2));
22
31
  } catch (thrown) {
23
32
  setShown(thrown instanceof Error ? thrown.message : String(thrown));
24
33
  }
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { useContext, useEffect, useRef } from "react";
3
+ import { type AgentSessionOptions, SessionContext, type SessionHistory } from "../../vendor/use-agentic";
4
+
5
+ export interface HistoryProps {
6
+ load: SessionHistory["load"];
7
+ save: SessionHistory["save"];
8
+ clear: SessionHistory["clear"];
9
+ /** Where a host with its own server-side summary moves its watermark — see `onCompact` on the session options. */
10
+ onCompact?: AgentSessionOptions["onCompact"];
11
+ }
12
+
13
+ /**
14
+ * Puts the enclosing zone's transcript wherever the app keeps it, as a mounted component rather than a prop.
15
+ *
16
+ * `persist` does the same thing and has to be passed to whoever builds the session, which makes every ancestor up
17
+ * to that point a client component — a function cannot cross the server/client boundary as a prop. Mounted here
18
+ * instead, the only client module an app needs is this leaf, and `Agent.Zone` and the chat inside it can be
19
+ * assembled by a server component. Same shape as `Agent.Guide`, and it renders nothing.
20
+ *
21
+ * Restoring follows the session's one rule: it lands only while nothing has happened to the conversation yet, so
22
+ * mounting with the zone restores and mounting later saves from there on.
23
+ */
24
+ export const History = ({ load, save, clear, onCompact }: HistoryProps) => {
25
+ const session = useContext(SessionContext);
26
+ if (!session) throw new Error("Agent.History needs an enclosing Agent.Zone or AgentProvider to hold the session.");
27
+
28
+ const latest = useRef({ load, save, clear, onCompact });
29
+ latest.current = { load, save, clear, onCompact };
30
+ useEffect(() => {
31
+ session.setHistory({
32
+ load: () => latest.current.load(),
33
+ save: (messages) => latest.current.save(messages),
34
+ clear: () => latest.current.clear(),
35
+ });
36
+ session.setOnCompact((replaced, summary) => latest.current.onCompact?.(replaced, summary));
37
+ return () => {
38
+
39
+ session.setHistory(null);
40
+ session.setOnCompact(null);
41
+ };
42
+ }, [session]);
43
+ return null;
44
+ };
package/ui/Agent/Zone.tsx CHANGED
@@ -56,6 +56,10 @@ export interface ZoneProps {
56
56
  * inside — hook tools, `st.use` subscriptions, guides — belongs to this zone's session *and* to the root agent:
57
57
  * zones are views, never walls. An `Agent.Chat` mounted inside binds to this session automatically, so two zones
58
58
  * on one screen run two conversations in parallel, each seeing only its own subtree.
59
+ *
60
+ * **Everything a zone publishes is named `<id>.<name>`.** Instructions that name a tool must carry the prefix —
61
+ * a bare name is a tool that does not exist, and the model calling it spends a turn on `Unknown tool`. Build the
62
+ * name from the id rather than writing it twice, and read `Agent.Context`'s Assemble to see the published list.
59
63
  */
60
64
  export const Zone = ({
61
65
  className,
package/ui/Agent/index.ts CHANGED
@@ -2,6 +2,7 @@ import { AgentScope } from "../../vendor/use-agentic";
2
2
  import Context from "./Context";
3
3
  import { Dock } from "./Dock";
4
4
  import { Guide } from "./Guide";
5
+ import { History } from "./History";
5
6
  import { Chat } from "./index_";
6
7
  import Section from "./Section";
7
8
  import { Skip } from "./Skip";
@@ -15,6 +16,7 @@ export const Agent = {
15
16
  Context,
16
17
  Dock,
17
18
  Guide,
19
+ History,
18
20
  Scope: AgentScope,
19
21
  Section,
20
22
  Skip,
package/ui/Field.tsx CHANGED
@@ -86,7 +86,7 @@ interface ListProps<Item> {
86
86
  label?: string;
87
87
  desc?: string;
88
88
  nullable?: boolean;
89
- value: Item[];
89
+ value: Item[] | null;
90
90
  onChange: (value: Item[]) => void;
91
91
  onAdd: () => void;
92
92
  renderItem: (item: Item, idx: number) => ReactNode;
@@ -105,11 +105,12 @@ const List = <Item,>({
105
105
  const { l } = usePage();
106
106
  const recipe = useUiRecipe("button") ?? buttonRecipe;
107
107
  useFieldTool(onChange);
108
+ const items = value ?? [];
108
109
  return (
109
110
  <div {...agentAttrs(onChange)} className={cn("flex w-full flex-col", className)}>
110
111
  {label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
111
112
  <div className="mb-2 flex w-full flex-col gap-2 rounded-box border border-border p-2">
112
- {value.map((item, idx) => (
113
+ {items.map((item, idx) => (
113
114
  <>
114
115
  <div key={idx} className="flex h-full w-full items-center justify-between gap-2">
115
116
  {renderItem(item, idx)}
@@ -120,7 +121,7 @@ const List = <Item,>({
120
121
  "size-6 border-destructive p-0 text-destructive hover:bg-destructive hover:text-destructive-foreground",
121
122
  )}
122
123
  onClick={() => {
123
- onChange(value.filter((_, i) => i !== idx));
124
+ onChange(items.filter((_, i) => i !== idx));
124
125
  }}
125
126
  >
126
127
  <BiTrash />
@@ -351,7 +352,7 @@ interface SwitchProps {
351
352
  desc?: string;
352
353
  labelClassName?: string;
353
354
  className?: string;
354
- value: boolean;
355
+ value: boolean | null;
355
356
  onChange: (value: boolean) => void;
356
357
  inputClassName?: string;
357
358
  onDesc?: string;
@@ -379,7 +380,7 @@ const Switch = ({
379
380
  variant="accent"
380
381
  disabled={disabled}
381
382
  className={inputClassName}
382
- checked={value}
383
+ checked={value ?? false}
383
384
  onChange={(checked) => {
384
385
  onChange(checked);
385
386
  }}
@@ -391,7 +392,7 @@ const Switch = ({
391
392
  };
392
393
  Field.Switch = Switch;
393
394
 
394
- interface ToggleSelectProps<I> {
395
+ interface ToggleSelectProps<I, Nullable extends boolean> {
395
396
  className?: string;
396
397
  labelClassName?: string;
397
398
  label?: string;
@@ -399,14 +400,14 @@ interface ToggleSelectProps<I> {
399
400
  model?: string;
400
401
  field?: string;
401
402
  items: { label: string; value: I; disabled?: boolean }[] | readonly I[] | I[] | EnumInstance<string, I>;
402
- value: I;
403
- nullable?: boolean;
403
+ value: I | null;
404
+ nullable?: Nullable;
404
405
  disabled?: boolean;
405
406
  validate?: (value: I) => boolean | string;
406
- onChange: (value: I) => void;
407
+ onChange: (value: Nullable extends true ? I | null : I) => void;
407
408
  btnClassName?: string;
408
409
  }
409
- const ToggleSelect = <I extends string | number | boolean | null>({
410
+ const ToggleSelect = <I extends string | number | boolean | null, Nullable extends boolean = false>({
410
411
  className,
411
412
  labelClassName,
412
413
  label,
@@ -418,14 +419,15 @@ const ToggleSelect = <I extends string | number | boolean | null>({
418
419
  nullable,
419
420
  disabled,
420
421
  btnClassName,
421
- }: ToggleSelectProps<I>) => {
422
+ }: ToggleSelectProps<I, Nullable>) => {
422
423
  useFieldTool(onChange, { disabled });
423
424
  const { l } = usePage();
424
425
  const isEnumValue = isEnum(items as EnumInstance<string, I>);
426
+ const change = onChange as (value: I | null) => void;
425
427
  return (
426
428
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
427
429
  {label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
428
- <UtilToggleSelect
430
+ <UtilToggleSelect<I | null>
429
431
  className="mt-2"
430
432
  nullable={!!nullable}
431
433
  btnClassName={btnClassName}
@@ -438,12 +440,15 @@ const ToggleSelect = <I extends string | number | boolean | null>({
438
440
  : (items as { label: string; value: I; disabled?: boolean }[])
439
441
  }
440
442
  value={value}
441
- onChange={(value: I, idx) => {
442
- onChange(value);
443
+ onChange={(selected) => {
444
+ change(selected);
445
+ }}
446
+ onClear={() => {
447
+ change(null);
443
448
  }}
444
449
  disabled={disabled}
445
- validate={(value: I) => {
446
- return validate?.(value) ?? true;
450
+ validate={(selected) => {
451
+ return selected === null ? true : (validate?.(selected) ?? true);
447
452
  }}
448
453
  />
449
454
  </div>
@@ -457,7 +462,7 @@ interface MultiToggleSelectProps<I extends string | number | boolean> {
457
462
  label?: string;
458
463
  desc?: string;
459
464
  items: EnumInstance<string, I> | { label: string; value: I; disabled?: boolean }[] | readonly I[] | I[];
460
- value: I[];
465
+ value: I[] | null;
461
466
  disabled?: boolean;
462
467
  minlength?: number;
463
468
  maxlength?: number;
@@ -482,7 +487,7 @@ const MultiToggleSelect = <I extends string | number | boolean>({
482
487
  const isEnumValue = isEnum(items as EnumInstance<string, I>);
483
488
  return (
484
489
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
485
- {label ? <Label className={labelClassName} nullable={!!minlength} label={label} desc={desc} /> : null}
490
+ {label ? <Label className={labelClassName} nullable={!minlength} label={label} desc={desc} /> : null}
486
491
  <UtilToggleSelect.Multi
487
492
  nullable={!minlength}
488
493
  items={
@@ -493,7 +498,7 @@ const MultiToggleSelect = <I extends string | number | boolean>({
493
498
  })) as { label: string; value: string; disabled?: boolean }[])
494
499
  : (items as { label: string; value: string; disabled?: boolean }[])
495
500
  }
496
- value={value as string[]}
501
+ value={(value ?? []) as string[]}
497
502
  onChange={(values) => {
498
503
  onChange(values as I[]);
499
504
  }}
@@ -514,7 +519,7 @@ interface TextListProps {
514
519
  desc?: string;
515
520
  labelClassName?: string;
516
521
  className?: string;
517
- value: string[];
522
+ value: string[] | null;
518
523
  onChange: (value: string[]) => void;
519
524
  inputClassName?: string;
520
525
  placeholder?: string;
@@ -548,6 +553,7 @@ const TextList = ({
548
553
  useFieldTool(onChange, { transform, disabled, sortable: true });
549
554
  const { l } = usePage();
550
555
  const recipe = useUiRecipe("button") ?? buttonRecipe;
556
+ const texts = value ?? [];
551
557
  return (
552
558
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
553
559
  {label ? <Label className={labelClassName} nullable={!minlength} label={label} desc={desc} /> : null}
@@ -559,10 +565,10 @@ const TextList = ({
559
565
  onChange(sorted);
560
566
  }}
561
567
  onRemove={(_, idx) => {
562
- onChange(value.filter((_, i) => i !== idx));
568
+ onChange(texts.filter((_, i) => i !== idx));
563
569
  }}
564
570
  >
565
- {value.map((text, idx) => (
571
+ {texts.map((text, idx) => (
566
572
  <DraggableList.Item key={idx} value={text}>
567
573
  <div className="flex w-full items-center">
568
574
  <DraggableList.Cursor>
@@ -573,7 +579,7 @@ const TextList = ({
573
579
  value={text}
574
580
  cacheKey={cache ? `${label}-${desc}-textList-[${idx}]` : undefined}
575
581
  onChange={(text) => {
576
- const newValue = [...value];
582
+ const newValue = [...texts];
577
583
  newValue[idx] = transform(text);
578
584
  onChange(newValue);
579
585
  }}
@@ -593,7 +599,7 @@ const TextList = ({
593
599
  "size-6 border-destructive p-0 text-destructive hover:bg-destructive hover:text-destructive-foreground",
594
600
  )}
595
601
  onClick={() => {
596
- onChange(value.filter((_, i) => i !== idx));
602
+ onChange(texts.filter((_, i) => i !== idx));
597
603
  }}
598
604
  >
599
605
  <BiTrash />
@@ -604,11 +610,11 @@ const TextList = ({
604
610
  ))}
605
611
  </DraggableList>
606
612
  <div className="my-5 h-[0.5px] bg-foreground/20" />
607
- {value.length <= maxTextlength ? (
613
+ {texts.length <= maxTextlength ? (
608
614
  <button
609
615
  className={recipe({ variant: "outline" }, "w-full")}
610
616
  onClick={() => {
611
- onChange([...value, ""]);
617
+ onChange([...texts, ""]);
612
618
  }}
613
619
  >
614
620
  + New
@@ -625,7 +631,7 @@ interface TagsProps {
625
631
  desc?: string;
626
632
  labelClassName?: string;
627
633
  className?: string;
628
- value: string[];
634
+ value: string[] | null;
629
635
  onChange: (value: string[]) => void;
630
636
  inputClassName?: string;
631
637
  placeholder?: string;
@@ -658,11 +664,12 @@ const Tags = ({
658
664
  useFieldTool(onChange, { transform, disabled });
659
665
  const { l } = usePage();
660
666
  const badge = useUiRecipe("badge") ?? badgeRecipe;
667
+ const tagList = value ?? [];
661
668
  const [inputVisible, setInputVisible] = useState(false);
662
669
  const [tag, setTag] = useState("");
663
670
  const addTag = () => {
664
671
  if (!tag.length) return;
665
- onChange([...value, tag]);
672
+ onChange([...tagList, tag]);
666
673
  setInputVisible(false);
667
674
  setTag("");
668
675
  };
@@ -671,14 +678,14 @@ const Tags = ({
671
678
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
672
679
  {label ? <Label className={labelClassName} nullable={!minlength} label={label} desc={desc} /> : null}
673
680
  <div className="flex w-full flex-wrap items-center gap-1 rounded-box border border-border p-2">
674
- {value.map((val, idx) => (
681
+ {tagList.map((val, idx) => (
675
682
  <span className={badge({ variant: "outline" }, "items-center")} key={idx}>
676
683
  <div className="text-xs italic">#</div>
677
684
  {val}
678
685
  <BiX
679
686
  className="ml-1 cursor-pointer opacity-50 duration-200 hover:opacity-100"
680
687
  onClick={() => {
681
- if (!disabled) onChange(value.filter((v, i) => i !== idx));
688
+ if (!disabled) onChange(tagList.filter((v, i) => i !== idx));
682
689
  }}
683
690
  />
684
691
  </span>
@@ -1455,7 +1462,7 @@ interface ChildrenProps<T extends string, State, Input, Full, Light> {
1455
1462
  disabled?: boolean;
1456
1463
  nullable?: boolean;
1457
1464
  initArgs?: any[];
1458
- value: Light[];
1465
+ value: Light[] | null;
1459
1466
  onChange: (value: Light[]) => void;
1460
1467
  onSearch?: (text: string) => void;
1461
1468
  slice: SliceMeta;
@@ -1515,7 +1522,7 @@ const Children = <T extends string, State, Input, Full extends { id: string }, L
1515
1522
  labelClassName={labelClassName}
1516
1523
  selectClassName={selectClassName}
1517
1524
  multiple
1518
- value={value.map((model) => model.id)}
1525
+ value={(value ?? []).map((model) => model.id)}
1519
1526
  options={modelList.map((model) => {
1520
1527
  const label = renderOption(model);
1521
1528
  return { label: typeof label === "string" ? label : model.id, value: model.id };
@@ -1554,7 +1561,7 @@ interface ChildrenIdProps<T extends string, State, Input, Full, Light> {
1554
1561
  disabled?: boolean;
1555
1562
  nullable?: boolean;
1556
1563
  initArgs?: any[];
1557
- value: string[];
1564
+ value: string[] | null;
1558
1565
  slice: SliceMeta;
1559
1566
  onChange: (value: string[]) => void;
1560
1567
  onSearch?: (text: string) => void;
@@ -1607,7 +1614,7 @@ const ChildrenId = <T extends string, State, Input, Full extends { id: string },
1607
1614
  labelClassName={labelClassName}
1608
1615
  multiple
1609
1616
 
1610
- value={value}
1617
+ value={value ?? []}
1611
1618
  options={modelList.map((model) => {
1612
1619
  const label = renderOption(model);
1613
1620
  return { label: typeof label === "string" ? label : model.id, value: model.id };
@@ -16,6 +16,7 @@ export interface ToggleSelectProps<I extends string | number | boolean | null> {
16
16
  nullable: boolean;
17
17
  validate: (value: I) => boolean | string;
18
18
  onChange: (value: I, idx: number) => void;
19
+ onClear?: () => void;
19
20
  disabled?: boolean;
20
21
  }
21
22
  const DefaultToggleSelect = <I extends string | number | boolean | null>({
@@ -26,6 +27,7 @@ const DefaultToggleSelect = <I extends string | number | boolean | null>({
26
27
  validate,
27
28
  value,
28
29
  onChange,
30
+ onClear,
29
31
  disabled,
30
32
  }: ToggleSelectProps<I>) => {
31
33
  const { l } = usePage();
@@ -61,7 +63,8 @@ const DefaultToggleSelect = <I extends string | number | boolean | null>({
61
63
  disabled={isDisabled}
62
64
  className={cn(toggleBtn, isSelected && selectedCls, isDisabled && "cursor-not-allowed", btnClassName)}
63
65
  onClick={() => {
64
- onChange(option.value, idx);
66
+ if (nullable && isSelected) onClear?.();
67
+ else onChange(option.value, idx);
65
68
  }}
66
69
  >
67
70
  {option.label}
package/ui/index.ts CHANGED
@@ -27,6 +27,7 @@ export type { ChatProps } from "./Agent/Chat";
27
27
  export { type ChatCommand, ChatCommands } from "./Agent/ChatCommands";
28
28
  export { type ComposerProps, DefaultComposer } from "./Agent/Composer";
29
29
  export { fetchRunner } from "./Agent/fetchRunner";
30
+ export type { HistoryProps as AgentHistoryProps } from "./Agent/History";
30
31
  export { DefaultLauncher, type LauncherProps } from "./Agent/Launcher";
31
32
  export { type CodeProps, DefaultCode, DefaultMarkdown, type MarkdownProps } from "./Agent/Markdown";
32
33
  export { DefaultMenu, type MenuProps as AgentMenuProps, type MenuRow } from "./Agent/Menu";
@@ -139,6 +139,9 @@ export class AgentSession {
139
139
  #saveTimer: ReturnType<typeof setTimeout> | null = null;
140
140
  #saving: Promise<unknown> = Promise.resolve();
141
141
  #restoring = false;
142
+
143
+ #history: SessionHistory | undefined;
144
+ #onCompact: AgentSessionOptions["onCompact"];
142
145
  #compacting = false;
143
146
  /** Size below which auto-compaction stays out of the way, raised when a summary failed to shrink anything. */
144
147
  #compactFloor = 0;
@@ -147,6 +150,8 @@ export class AgentSession {
147
150
  this.#surface = surface;
148
151
  this.#runner = runner;
149
152
  this.#options = options;
153
+ this.#history = options.history;
154
+ this.#onCompact = options.onCompact;
150
155
  const restored = AgentSession.#restored(options.history);
151
156
  if (Array.isArray(restored)) this.#messages = restored;
152
157
  else {
@@ -291,7 +296,7 @@ export class AgentSession {
291
296
  clearTimeout(this.#saveTimer);
292
297
  this.#saveTimer = null;
293
298
  }
294
- const history = this.#options.history;
299
+ const history = this.#history;
295
300
  if (history) {
296
301
 
297
302
  this.#saving = this.#saving.then(() => history.clear()).catch(() => undefined);
@@ -301,6 +306,31 @@ export class AgentSession {
301
306
  for (const listener of this.#listeners) listener();
302
307
  };
303
308
 
309
+ /**
310
+ * Attaches a transcript store to a session built without one — what `Agent.History` mounts, so a zone can be
311
+ * assembled by a server component and still keep its transcript wherever the app keeps it. `null` detaches.
312
+ *
313
+ * Restoring follows the rule an async `load` already follows: it lands only while nothing has happened to this
314
+ * session yet. Attach before the first turn and it restores; attach after and it saves from there on, with the
315
+ * store never asked for a transcript that would be discarded. One rule rather than a mount-order surprise.
316
+ */
317
+ setHistory = (history: SessionHistory | null) => {
318
+ this.#history = history ?? undefined;
319
+ if (!history || this.#version !== 0) return;
320
+ const restored = AgentSession.#restored(history);
321
+ if (!(restored instanceof Promise)) {
322
+ this.#restore(restored);
323
+ return;
324
+ }
325
+ this.#restoring = true;
326
+ void this.#hydrate(restored);
327
+ };
328
+
329
+ /** The compaction hook as a setter, for the same reason `setHistory` is one: a host attaches it after the fact. */
330
+ setOnCompact = (onCompact: AgentSessionOptions["onCompact"] | null) => {
331
+ this.#onCompact = onCompact ?? undefined;
332
+ };
333
+
304
334
  /**
305
335
  * Re-runs the last user message, dropping what the previous attempt produced. Turns fail for reasons that have
306
336
  * nothing to do with what was asked — a refused relay, a model that is unavailable — and retyping is otherwise the
@@ -378,7 +408,7 @@ export class AgentSession {
378
408
  const message = Compaction.message(summary);
379
409
  this.#messages = [message, ...this.#messages.slice(at)];
380
410
  try {
381
- this.#options.onCompact?.(replaced, message);
411
+ this.#onCompact?.(replaced, message);
382
412
  } catch {
383
413
  }
384
414
  return true;
@@ -657,7 +687,7 @@ export class AgentSession {
657
687
 
658
688
  /** Debounced: streaming patches the last message on every delta, and a save per delta would thrash storage. */
659
689
  #schedulePersist() {
660
- const history = this.#options.history;
690
+ const history = this.#history;
661
691
  if (!history) return;
662
692
  if (this.#saveTimer) clearTimeout(this.#saveTimer);
663
693
  this.#saveTimer = setTimeout(() => {
@@ -696,6 +726,15 @@ export class AgentSession {
696
726
  } catch {
697
727
  }
698
728
  this.#restoring = false;
729
+ this.#restore(restored);
730
+ }
731
+
732
+ /**
733
+ * Lands a restore under the one rule — only into a session nothing has happened to yet — and notifies either
734
+ * way, because `isRestoring` may have turned over with it. Notifies by hand rather than through `#notify`,
735
+ * which would save the transcript it has just loaded.
736
+ */
737
+ #restore(restored: ChatMessage[]) {
699
738
  if (this.#version === 0 && restored.length) this.#messages = restored;
700
739
  this.#version += 1;
701
740
  for (const listener of this.#listeners) listener();
@@ -8,6 +8,7 @@ export * from "./AgentScope";
8
8
  export * from "./AgentSession";
9
9
  export * from "./Compaction";
10
10
  export * from "./httpRunner";
11
+ export * from "./sharedContext";
11
12
  export * from "./surfaceContext";
12
13
  export * from "./ToolOutput";
13
14
  export * from "./Transcript";
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { type Context, createContext } from "react";
3
+
4
+ /**
5
+ * A React context interned on `globalThis`, for the reason `AgenticSurface.shared` is: an app does not get one
6
+ * copy of this package. An akan build inlines it into every client bundle that reaches it — four of them in this
7
+ * repo's own docs app — and a context is identified by object identity, so a `ScopeContext.Provider` rendered by
8
+ * `Agent.Zone` in one copy is invisible to the `st.tool` reading it from another.
9
+ *
10
+ * That failure is silent, which is what makes it worth a global: the read falls back to the context's default, so
11
+ * the tool registers at the root scope instead of the zone's, the zone session filters it out as belonging to a
12
+ * different view, and the model is handed the built-ins with nothing thrown and nothing logged.
13
+ *
14
+ * Every context this package owns goes through here. Adding one the plain way would work in the monorepo — one
15
+ * copy, one object — and fail only once bundled, which is exactly the bug this replaced.
16
+ */
17
+ export const sharedContext = <T>(name: string, initial: T): Context<T> => {
18
+ const key = Symbol.for(`useAgentic.context.${name}`);
19
+ const holder = globalThis as typeof globalThis & { [slot: symbol]: Context<T> | undefined };
20
+ const existing = holder[key];
21
+ if (existing) return existing;
22
+ const created = createContext(initial);
23
+ holder[key] = created;
24
+ return created;
25
+ };
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { createContext, useContext } from "react";
2
+ import { useContext } from "react";
3
3
  import { AgenticSurface } from "./AgenticSurface";
4
+ import { sharedContext } from "./sharedContext";
4
5
 
5
- export const SurfaceContext = createContext<AgenticSurface | null>(null);
6
- export const ScopeContext = createContext<string[]>([]);
6
+ export const SurfaceContext = sharedContext<AgenticSurface | null>("surface", null);
7
+ export const ScopeContext = sharedContext<string[]>("scope", []);
7
8
 
8
9
  export const useSurface = () => useContext(SurfaceContext) ?? AgenticSurface.shared;
9
10
  export const useScopePath = () => useContext(ScopeContext);
@@ -1,8 +1,9 @@
1
1
  "use client";
2
- import { createContext, useContext, useSyncExternalStore } from "react";
2
+ import { useContext, useSyncExternalStore } from "react";
3
3
  import type { AgentSession } from "./AgentSession";
4
+ import { sharedContext } from "./sharedContext";
4
5
 
5
- export const SessionContext = createContext<AgentSession | null>(null);
6
+ export const SessionContext = sharedContext<AgentSession | null>("session", null);
6
7
 
7
8
  /** The enclosing session, re-rendering on every session change. `send`/`abort` are safe to destructure. */
8
9
  export const useAgent = (): AgentSession => {