akanjs 3.0.0-alpha.55 → 3.0.0-alpha.56

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.56",
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
+ }
@@ -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 {};
@@ -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;
@@ -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 {
@@ -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>;
@@ -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
  }
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/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}
@@ -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 => {