toolnexus 0.18.0 → 0.19.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/src/client.ts CHANGED
@@ -56,7 +56,18 @@ export interface ClientOptions {
56
56
  /** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
57
57
  * this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
58
58
  * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
59
- * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
59
+ * host: run() does not hang — it returns { status:"pending", pending:request } to resume later.
60
+ *
61
+ * IDEMPOTENCY CONTRACT (SPEC §7D rewind-to-checkpoint). A durable resume REPLAYS the suspended
62
+ * turn from its PRE-TURN checkpoint: "continues from its checkpoint" means the transcript is
63
+ * REWOUND, not carried. EVERY tool that ran in that turn RUNS AGAIN, and the suspended tool
64
+ * itself is always re-executed (with `ctx.answer`) — that is §10's resolution mechanism, not a
65
+ * bug. Reattachment by task key makes a re-run `task` call idempotent; a leaf agent's own
66
+ * side-effecting tools (`git push`, a charge, a delete) have NO equivalent and are the host's
67
+ * responsibility. Any tool reachable in a suspendable turn must be idempotent.
68
+ * Replaying the leaf's stored transcript instead is DEFERRED to its own change; it would not
69
+ * remove this requirement anyway.
70
+ */
60
71
  waitFor?: (request: Request) => Promise<Answer>
61
72
  /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
62
73
  * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
@@ -84,6 +95,77 @@ export interface ClientOptions {
84
95
  onUnsupportedPart?: UnsupportedPartMode
85
96
  }
86
97
 
98
+ /**
99
+ * The §8 CLIENT status vocabulary, as a value. It is a DIFFERENT, smaller closed set than the
100
+ * §7D AGENT vocabulary (`agents.TASK_STATUSES`), which also contains `"timeout"`, `"closed"`,
101
+ * `"interrupted"` and `"error"`. Two fields are called `status`; they are not interchangeable.
102
+ * A run that fails at a deadline THROWS here — it never comes back as a `RunResult`.
103
+ */
104
+ export const RUN_STATUSES = ["done", "pending", "incomplete"] as const
105
+ export type RunStatus = (typeof RUN_STATUSES)[number]
106
+
107
+ /** Account-identifier keys blanked out of a provider error body before it reaches a message,
108
+ * a log or a metric. The shape survives; the value does not (ADR 0027 D3.2). */
109
+ export const REDACTED_BODY_KEYS = ["user_id", "account_id", "org_id", "organization"] as const
110
+ /** What a redacted value is replaced with — identical in all seven ports. */
111
+ export const REDACTION_PLACEHOLDER = "\u00abredacted\u00bb"
112
+ /** A redacted body is capped at this many characters IN THE MESSAGE ONLY. The typed `body`
113
+ * field carries the full redacted body — a host that opted into a typed error asked for the
114
+ * whole thing (ADR 0027 D3 / addendum A5). */
115
+ export const ERROR_BODY_CAP = 200
116
+
117
+ /**
118
+ * Redact known account identifiers, then cap. A CAP IS NOT REDACTION: the body that leaked
119
+ * `user_2…` in issue #92 was 96 bytes, well inside the cap, so both steps are needed and
120
+ * redaction runs FIRST (capping first could split a key/value pair and hide it from the regex).
121
+ */
122
+ export function redactErrorBody(body: string): string {
123
+ let out = body
124
+ for (const key of REDACTED_BODY_KEYS) {
125
+ // JSON ("key": "value" / "key": 123) and form/query (key=value) spellings alike.
126
+ out = out.replace(new RegExp(`("${key}"\\s*:\\s*)("(?:[^"\\\\]|\\\\.)*"|-?\\d+(?:\\.\\d+)?|true|false|null)`, "g"), `$1"${REDACTION_PLACEHOLDER}"`)
127
+ out = out.replace(new RegExp(`\\b${key}=([^&\\s]+)`, "g"), `${key}=${REDACTION_PLACEHOLDER}`)
128
+ }
129
+ return out.trim()
130
+ }
131
+
132
+ /** Cap a already-redacted body for interpolation into a MESSAGE. Redaction is not a cap and a
133
+ * cap is not redaction: they are independent and both are needed. */
134
+ export function capErrorBody(body: string): string {
135
+ return body.length > ERROR_BODY_CAP ? body.slice(0, ERROR_BODY_CAP) + "\u2026" : body
136
+ }
137
+
138
+ /**
139
+ * A provider failure carried as a VALUE, not a sentence (ADR 0027 D3.1). A host that must
140
+ * decide what to log can read `status` / `retryAfter` instead of parsing a message.
141
+ *
142
+ * `body` is ALREADY redacted and capped, and is `""` on 401/403 — an auth body routinely
143
+ * reflects the credential or the header that was sent. `rawBody` is never populated by the
144
+ * library; the SPEC credentials guarantee now covers error messages too.
145
+ */
146
+ export class LlmHttpError extends Error {
147
+ readonly name = "LlmHttpError"
148
+ constructor(
149
+ /** The HTTP status the provider answered with. */
150
+ readonly status: number,
151
+ /** The provider's FULL redacted body; `""` on 401/403 and when empty. Uncapped — the cap
152
+ * applies to `message` only. */
153
+ readonly body: string,
154
+ /** The `Retry-After` header verbatim, when the provider sent one. */
155
+ readonly retryAfter?: string,
156
+ ) {
157
+ super(`LLM ${status}${body ? ": " + capErrorBody(body) : ""}`)
158
+ }
159
+ }
160
+
161
+ /** Build the typed error from a non-2xx provider response (reads the body once). */
162
+ export async function llmHttpError(res: Response): Promise<LlmHttpError> {
163
+ const raw = await res.text().catch(() => "")
164
+ // 401/403: never echo the body — see `Classifier.cause`, now one policy, not two.
165
+ const body = res.status === 401 || res.status === 403 ? "" : redactErrorBody(raw)
166
+ return new LlmHttpError(res.status, body, res.headers.get("retry-after") ?? undefined)
167
+ }
168
+
87
169
  /** §8 Resilience. What to do with a failed LLM call. */
88
170
  export type ErrorTier = "retry" | "fail"
89
171
 
@@ -443,11 +525,17 @@ export class Client {
443
525
  this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) })
444
526
  }
445
527
 
446
- async run(prompt: PromptInput, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
528
+ /**
529
+ * Run the loop. `ctx` — and `ctx.toolkit` inside it — are OPTIONAL: an absent toolkit is a
530
+ * COMPLETION (SPEC §0.10). The system message is then the system prompt alone, and the
531
+ * request body carries no `tools` and no `tool_choice` key (not an empty array). An empty
532
+ * toolkit and no toolkit are observably identical on the wire.
533
+ */
534
+ async run(prompt: PromptInput, ctx?: { toolkit?: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
447
535
  checkPromptParts(prompt, this.wireOpts())
448
536
  return this.opts.style === "anthropic"
449
- ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
450
- : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history)
537
+ ? this.runAnthropic(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
538
+ : this.runOpenAI(prompt, ctx?.toolkit, ctx?.signal, ctx?.history)
451
539
  }
452
540
 
453
541
  /**
@@ -609,10 +697,10 @@ export class Client {
609
697
  * the answer — so the next `ask` with the same `id` continues it. Without an
610
698
  * `id` it is a stateless one-shot (identical to `run`).
611
699
  */
612
- async ask(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
700
+ async ask(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
613
701
  // Block-style streaming: run the streaming loop, forward text deltas, still return the
614
702
  // final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
615
- if (ctx.on_text) {
703
+ if (ctx?.on_text) {
616
704
  let result: RunResult | undefined
617
705
  for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
618
706
  if (ev.type === "text") ctx.on_text(ev.delta)
@@ -620,7 +708,7 @@ export class Client {
620
708
  }
621
709
  return result!
622
710
  }
623
- if (!ctx.id) return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal })
711
+ if (!ctx?.id) return this.run(prompt, { toolkit: ctx?.toolkit, signal: ctx?.signal })
624
712
  const history = (await this.store.get(ctx.id)) ?? []
625
713
  const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
626
714
  await this.store.save(ctx.id, result.messages)
@@ -628,8 +716,8 @@ export class Client {
628
716
  }
629
717
 
630
718
  /** A stateful multi-turn conversation that retains history across sends. */
631
- conversation(ctx: { toolkit: Toolkit; signal?: AbortSignal }): Conversation {
632
- return new Conversation(this, ctx.toolkit, ctx.signal)
719
+ conversation(ctx?: { toolkit?: Toolkit; signal?: AbortSignal }): Conversation {
720
+ return new Conversation(this, ctx?.toolkit, ctx?.signal)
633
721
  }
634
722
 
635
723
  /**
@@ -637,20 +725,22 @@ export class Client {
637
725
  * With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
638
726
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
639
727
  */
640
- async *stream(prompt: PromptInput, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
728
+ async *stream(prompt: PromptInput, ctx?: { toolkit?: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
641
729
  checkPromptParts(prompt, this.wireOpts())
642
- const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
730
+ const history = ctx?.id ? (await this.store.get(ctx.id)) ?? [] : undefined
643
731
  const gen = this.opts.style === "anthropic"
644
- ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
645
- : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history)
732
+ ? this.streamAnthropic(prompt, ctx?.toolkit, ctx?.signal, history)
733
+ : this.streamOpenAI(prompt, ctx?.toolkit, ctx?.signal, history)
646
734
  for await (const ev of gen) {
647
- if (ev.type === "done" && ctx.id) await this.store.save(ctx.id, ev.result.messages)
735
+ if (ev.type === "done" && ctx?.id) await this.store.save(ctx.id, ev.result.messages)
648
736
  yield ev
649
737
  }
650
738
  }
651
739
 
652
- private system(toolkit: Toolkit): string {
653
- return [this.opts.systemPrompt ?? "", toolkit.skillsPrompt()].filter(Boolean).join("\n\n")
740
+ /** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
741
+ * the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
742
+ private system(toolkit?: Toolkit): string {
743
+ return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n")
654
744
  }
655
745
 
656
746
  /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
@@ -699,9 +789,9 @@ export class Client {
699
789
  }
700
790
 
701
791
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
702
- private async runTool(toolkit: Toolkit, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
792
+ private async runTool(toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
703
793
  const h = this.opts.hooks
704
- const source = toolkit.get(name)?.source ?? "custom"
794
+ const source = toolkit?.get(name)?.source ?? "custom"
705
795
  const t0 = Date.now()
706
796
  let a = args
707
797
  if (h?.beforeTool) {
@@ -714,7 +804,10 @@ export class Client {
714
804
  }
715
805
  if (ov?.args) a = ov.args
716
806
  }
717
- let result = await toolkit.execute(name, a)
807
+ // No toolkit ⇒ nothing was declared, so a tool call is the provider inventing one.
808
+ let result: ToolResult = toolkit
809
+ ? await toolkit.execute(name, a)
810
+ : { output: `unknown tool: ${name}`, isError: true }
718
811
  // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
719
812
  // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
720
813
  // a tool error.
@@ -737,7 +830,7 @@ export class Client {
737
830
  const t0 = Date.now()
738
831
  try {
739
832
  const res = await this.llmFetch(url, init, signal)
740
- if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
833
+ if (!res.ok) throw await llmHttpError(res)
741
834
  const data: any = await res.json()
742
835
  const tok = perCall(data.usage, style)
743
836
  this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
@@ -749,7 +842,7 @@ export class Client {
749
842
  }
750
843
 
751
844
  // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
752
- private async runOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
845
+ private async runOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
753
846
  const key = resolveKey(this.opts)
754
847
  const signal = this.makeSignal(external)
755
848
  const runStart = Date.now()
@@ -759,7 +852,7 @@ export class Client {
759
852
  if (system) messages.push({ role: "system", content: system })
760
853
  }
761
854
  messages.push({ role: "user", content: prompt })
762
- let tools = toolkit.toOpenAI()
855
+ let tools = toolkit?.toOpenAI() ?? []
763
856
  const toolCalls: ToolCallRecord[] = []
764
857
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
765
858
  let turns = 0
@@ -832,24 +925,26 @@ export class Client {
832
925
  * no `waitFor` is configured (the run should stop and surface the request).
833
926
  */
834
927
  private async resolvePending(
835
- toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
928
+ toolkit: Toolkit | undefined, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
836
929
  ): Promise<{ result: ToolResult; halted?: Request }> {
837
930
  if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
838
931
  const answer = await this.opts.waitFor(request)
839
932
  if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
840
933
  const t0 = Date.now()
841
- let result = await toolkit.execute(name, args, { answer })
934
+ let result: ToolResult = toolkit
935
+ ? await toolkit.execute(name, args, { answer })
936
+ : { output: `unknown tool: ${name}`, isError: true }
842
937
  if (this.opts.hooks?.afterTool) {
843
938
  const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
844
939
  if (ov?.result) result = ov.result
845
940
  }
846
- this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0)
941
+ this.emitTool(name, toolkit?.get(name)?.source ?? "custom", result, t0)
847
942
  if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
848
943
  return { result }
849
944
  }
850
945
 
851
946
  // ---- Anthropic-style: POST {baseUrl}/messages ----
852
- private async runAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
947
+ private async runAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): Promise<RunResult> {
853
948
  const key = resolveKey(this.opts)
854
949
  const signal = this.makeSignal(external)
855
950
  const base = this.opts.baseUrl.replace(/\/$/, "")
@@ -857,7 +952,7 @@ export class Client {
857
952
  const system = this.system(toolkit)
858
953
  const runStart = Date.now()
859
954
  let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
860
- let tools = toolkit.toAnthropic()
955
+ let tools = toolkit?.toAnthropic() ?? []
861
956
  const toolCalls: ToolCallRecord[] = []
862
957
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
863
958
  let turns = 0
@@ -923,7 +1018,7 @@ export class Client {
923
1018
  }
924
1019
 
925
1020
  // ---- Streaming: OpenAI-style ----
926
- private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1021
+ private async *streamOpenAI(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
927
1022
  const key = resolveKey(this.opts)
928
1023
  const signal = this.makeSignal(external)
929
1024
  const runStart = Date.now()
@@ -933,7 +1028,7 @@ export class Client {
933
1028
  if (system) messages.push({ role: "system", content: system })
934
1029
  }
935
1030
  messages.push({ role: "user", content: prompt })
936
- let tools = toolkit.toOpenAI()
1031
+ let tools = toolkit?.toOpenAI() ?? []
937
1032
  const toolCalls: ToolCallRecord[] = []
938
1033
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
939
1034
  let turns = 0
@@ -956,7 +1051,7 @@ export class Client {
956
1051
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
957
1052
  body: JSON.stringify(this.openaiBody(messages, tools, true)),
958
1053
  }, signal)
959
- if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
1054
+ if (!res.ok || !res.body) throw await llmHttpError(res)
960
1055
  for await (const line of sseLines(res.body)) {
961
1056
  if (!line.startsWith("data:")) continue
962
1057
  const payload = line.slice(5).trim()
@@ -1019,7 +1114,7 @@ export class Client {
1019
1114
  }
1020
1115
 
1021
1116
  // ---- Streaming: Anthropic-style ----
1022
- private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1117
+ private async *streamAnthropic(prompt: PromptInput, toolkit: Toolkit | undefined, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
1023
1118
  const key = resolveKey(this.opts)
1024
1119
  const signal = this.makeSignal(external)
1025
1120
  const runStart = Date.now()
@@ -1027,7 +1122,7 @@ export class Client {
1027
1122
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
1028
1123
  const system = this.system(toolkit)
1029
1124
  let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
1030
- let tools = toolkit.toAnthropic()
1125
+ let tools = toolkit?.toAnthropic() ?? []
1031
1126
  const toolCalls: ToolCallRecord[] = []
1032
1127
  const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
1033
1128
  let turns = 0
@@ -1050,7 +1145,7 @@ export class Client {
1050
1145
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
1051
1146
  body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
1052
1147
  }, signal)
1053
- if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
1148
+ if (!res.ok || !res.body) throw await llmHttpError(res)
1054
1149
  for await (const line of sseLines(res.body)) {
1055
1150
  if (!line.startsWith("data:")) continue
1056
1151
  const j = safeParse(line.slice(5).trim())
@@ -1162,19 +1257,18 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
1162
1257
  const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
1163
1258
 
1164
1259
  /**
1165
- * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1166
- * HTTP types to construct.
1167
- *
1168
- * This is a second constructor, not a second seam: it builds on the same injectable
1169
- * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1170
- * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1260
+ * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
1261
+ * the reusable piece of {@link createInProcessClient} — assembles the wire request,
1262
+ * calls `generate`, and re-encodes its answer as an OpenAI-shaped `Response`. Every
1263
+ * caller that wants an in-process model behind a `fetch` slot (the top-level client,
1264
+ * or the agent runtime's `inProcess` option) goes through this ONE function, so there
1265
+ * is no second copy of the request/response assembly to drift.
1171
1266
  *
1172
1267
  * Streaming is refused rather than faked — see the thrown error.
1173
1268
  */
1174
- export function createInProcessClient(opts: InProcessOptions): Client {
1175
- const { generate, ...rest } = opts
1269
+ export function createInProcessFetch(generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>): typeof fetch {
1176
1270
  if (typeof generate !== "function") {
1177
- throw new Error("toolnexus: createInProcessClient requires a `generate` function")
1271
+ throw new Error("toolnexus: createInProcessFetch requires a `generate` function")
1178
1272
  }
1179
1273
 
1180
1274
  const inProcessFetch: typeof fetch = async (_url, init) => {
@@ -1229,6 +1323,25 @@ export function createInProcessClient(opts: InProcessOptions): Client {
1229
1323
  )
1230
1324
  }
1231
1325
 
1326
+ return inProcessFetch
1327
+ }
1328
+
1329
+ /**
1330
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1331
+ * HTTP types to construct.
1332
+ *
1333
+ * This is a second constructor, not a second seam: it builds on the same injectable
1334
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
1335
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
1336
+ * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
1337
+ * is just that adapter wired into a `Client`.
1338
+ *
1339
+ * Streaming is refused rather than faked — see {@link createInProcessFetch}.
1340
+ */
1341
+ export function createInProcessClient(opts: InProcessOptions): Client {
1342
+ const { generate, ...rest } = opts
1343
+ const inProcessFetch = createInProcessFetch(generate)
1344
+
1232
1345
  return new Client({
1233
1346
  // Retries default to ZERO here, unlike a network client. There is no wire, so
1234
1347
  // there is no transient failure to ride out: whatever `generate` throws will
@@ -1249,7 +1362,7 @@ export function createInProcessClient(opts: InProcessOptions): Client {
1249
1362
  export class Conversation {
1250
1363
  /** Full running transcript (system + user + assistant + tool messages). */
1251
1364
  messages: any[] = []
1252
- constructor(private readonly client: Client, private readonly toolkit: Toolkit, private readonly signal?: AbortSignal) {}
1365
+ constructor(private readonly client: Client, private readonly toolkit?: Toolkit, private readonly signal?: AbortSignal) {}
1253
1366
 
1254
1367
  /** Send the next user turn; prior history is retained automatically. */
1255
1368
  async send(prompt: PromptInput): Promise<RunResult> {
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./builtin.js"
7
7
  export * from "./native.js"
8
8
  export * from "./http.js"
9
9
  export * from "./a2a.js"
10
+ export * from "./acp.js"
10
11
  export * from "./serve.js"
11
12
  export * from "./mcpserve.js"
12
13
  export * from "./adapters.js"
package/src/order.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Deterministic ordering for everything the library lists (addenda A1a, A15, A22, A24).
3
+ *
4
+ * INTERNAL — deliberately not re-exported from `index.ts`. These are not API; they are the one
5
+ * place the ordering rules live so no caller has to remember them.
6
+ *
7
+ * The rules, and why each exists:
8
+ *
9
+ * - **Never `localeCompare`.** It orders by the machine's ICU data, so the same build emits a
10
+ * different order on a different machine. A cross-port byte-identity claim cannot survive it.
11
+ * - **Never a bare `Array.sort()`.** JS's default compares UTF-16 CODE UNITS, which disagrees
12
+ * with code point above U+FFFF — the same divergence C#'s `StringComparer.Ordinal` has.
13
+ * - **Never an ABSENT sort over a directory read.** `readdirSync` is sorted on most platforms,
14
+ * but Node guarantees nothing, and a walk that breaks at a cap turns that accident into a
15
+ * CONTENT difference: which files appear, not merely their order.
16
+ */
17
+
18
+ /**
19
+ * Unicode CODE-POINT comparison — the one string ordering rule, identical in all seven ports.
20
+ * Iterates code points rather than code units, so a surrogate pair sorts after U+E000–U+FFFF
21
+ * (where `<`, `Array.sort()` and `StringComparer.Ordinal` all disagree with it).
22
+ */
23
+ export function compareCodePoints(a: string, b: string): number {
24
+ if (a === b) return 0
25
+ const ai = a[Symbol.iterator]()
26
+ const bi = b[Symbol.iterator]()
27
+ for (;;) {
28
+ const x = ai.next()
29
+ const y = bi.next()
30
+ if (x.done) return y.done ? 0 : -1
31
+ if (y.done) return 1
32
+ const cx = x.value.codePointAt(0)!
33
+ const cy = y.value.codePointAt(0)!
34
+ if (cx !== cy) return cx < cy ? -1 : 1
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The SKILL DISCOVERY order (addendum A15): DEPTH (path segments) ascending, then code point,
40
+ * over a path relative to the root. First-wins then keeps the shallowest copy of a duplicated
41
+ * name, uniformly — code point alone makes the winner depend on the skill's own first letter
42
+ * (`docx` beats `synced/…`, but `xlsx` loses to it).
43
+ */
44
+ export function compareDiscovery(a: string, b: string, sep: string): number {
45
+ const da = a.split(sep).length
46
+ const db = b.split(sep).length
47
+ if (da !== db) return da < db ? -1 : 1
48
+ return compareCodePoints(a, b)
49
+ }
50
+
51
+ /**
52
+ * Sort a directory's entries BY NAME, in code-point order, in place.
53
+ *
54
+ * By NAME at each level, never by full path: that is what makes ports walking the same tree with
55
+ * the same stack produce the same sequence, which matters because a capped walk's cap decides
56
+ * WHICH entries are reached.
57
+ */
58
+ export function sortEntriesByName<T extends { name: string }>(entries: T[]): T[] {
59
+ return entries.sort((x, y) => compareCodePoints(x.name, y.name))
60
+ }
61
+
62
+ /**
63
+ * The listing path form (addendum A28): a relative path with `/` as the separator ON EVERY
64
+ * PLATFORM. Every capped or ordered listing EMITS this string and SORTS ON THE SAME STRING —
65
+ * ordering by one thing and showing another is how a listing ends up looking unsorted to its
66
+ * reader while passing its own test. Takes an already-relative path (`path.relative(...)`).
67
+ */
68
+ export function toPosixPath(rel: string, sep: string): string {
69
+ return sep === "/" ? rel : rel.split(sep).join("/")
70
+ }