@alint-js/model-adapter-acp 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/index.mjs +105 -52
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -95,4 +95,5 @@ endpoint = "http://127.0.0.1:7419/v1"
95
95
 
96
96
  - Use an alint agent adapter when a rule intentionally depends on agent-specific behavior.
97
97
  - Do not use this package as an OpenAI Responses, realtime, audio, or image compatibility layer.
98
- - Streaming tool calls and parallel tool calls are not implemented yet; text streaming is supported.
98
+ - Sequential tool calls work with streaming and non-streaming chat completions.
99
+ - Parallel tool calls are not supported.
package/dist/index.mjs CHANGED
@@ -179,6 +179,11 @@ function inputSchema(tool) {
179
179
  }
180
180
  //#endregion
181
181
  //#region src/gateway.ts
182
+ const zeroUsage = {
183
+ inputTokens: 0,
184
+ outputTokens: 0,
185
+ totalTokens: 0
186
+ };
182
187
  const openAIFunctionToolSchema = object({
183
188
  function: object({
184
189
  description: optional(string()),
@@ -265,6 +270,7 @@ var CompletionTurn = class {
265
270
  #cancelSession;
266
271
  #connection;
267
272
  #connectionDisposed = false;
273
+ #deferredText;
268
274
  #finished = false;
269
275
  #options;
270
276
  #outputs = new PromiseQueue();
@@ -278,6 +284,7 @@ var CompletionTurn = class {
278
284
  this.#toolCallCount += 1;
279
285
  this.#pendingCalls.set(call.id, call);
280
286
  options.onPending(call, this);
287
+ this.#flushDeferredText();
281
288
  this.#outputs.push({
282
289
  call,
283
290
  type: "tool-call"
@@ -336,6 +343,14 @@ var CompletionTurn = class {
336
343
  this.#finished = true;
337
344
  this.#options.onFinished(this, [...this.#pendingCalls.keys()]);
338
345
  }
346
+ #flushDeferredText() {
347
+ const chunks = this.#deferredText;
348
+ this.#deferredText = void 0;
349
+ for (const text of chunks ?? []) this.#outputs.push({
350
+ text,
351
+ type: "text-delta"
352
+ });
353
+ }
339
354
  #matchesContinuation(request, toolCallId) {
340
355
  const initialMessages = this.#options.request.messages;
341
356
  const initialPrefix = request.messages.slice(0, initialMessages.length);
@@ -345,6 +360,16 @@ var CompletionTurn = class {
345
360
  const toolCalls = assistantMessage.tool_calls;
346
361
  return Array.isArray(toolCalls) && toolCalls.some((call) => isRecord(call) && call.id === toolCallId && isRecord(call.function) && call.function.name === pendingCall.name && call.function.arguments === pendingCall.arguments);
347
362
  }
363
+ #pushText(text) {
364
+ if (this.#deferredText) {
365
+ this.#deferredText.push(text);
366
+ return;
367
+ }
368
+ this.#outputs.push({
369
+ text,
370
+ type: "text-delta"
371
+ });
372
+ }
348
373
  async #run() {
349
374
  const { cwd, model, origin, request } = this.#options;
350
375
  try {
@@ -373,27 +398,25 @@ var CompletionTurn = class {
373
398
  this.#assertActive();
374
399
  for (let attempt = 0; attempt < 2; attempt += 1) {
375
400
  const callCountBeforePrompt = this.#toolCallCount;
376
- const prompt = session.prompt(attempt === 0 ? renderMessages(request) : "The request requires a tool call. Call the available function before returning an answer.");
377
- let text = "";
401
+ this.#deferredText = requiresTool(request.toolChoice) ? [] : void 0;
402
+ const prompt = session.prompt(attempt === 0 ? renderMessages(request) : ["The request requires a request-scoped MCP tool call.", `Call one of these functions before returning an answer: ${this.#options.tools.map((tool) => tool.function.name).join(", ")}.`].join(" "));
378
403
  for (;;) {
379
404
  const message = await session.nextUpdate();
380
405
  if (message.kind === "stop") {
381
406
  await prompt;
382
407
  if (message.stopReason === "cancelled") throw new Error("The ACP agent cancelled the prompt.");
383
- if (requiresTool(request.toolChoice) && this.#toolCallCount === callCountBeforePrompt) break;
408
+ if (requiresTool(request.toolChoice) && this.#toolCallCount === callCountBeforePrompt) {
409
+ this.#deferredText = void 0;
410
+ break;
411
+ }
384
412
  this.#outputs.push({
385
413
  stopReason: message.stopReason,
386
- text,
387
414
  type: "completion",
388
415
  usage: message.response.usage
389
416
  });
390
417
  return;
391
418
  }
392
- if (message.update.sessionUpdate === "agent_message_chunk" && message.update.content.type === "text") if (request.stream) this.#outputs.push({
393
- text: message.update.content.text,
394
- type: "text-delta"
395
- });
396
- else text += message.update.content.text;
419
+ if (message.update.sessionUpdate === "agent_message_chunk" && message.update.content.type === "text") this.#pushText(message.update.content.text);
397
420
  }
398
421
  }
399
422
  throw new Error("The ACP agent did not satisfy tool_choice after one retry.");
@@ -450,10 +473,6 @@ function createGateway(options) {
450
473
  }
451
474
  const toolResult = lastToolResult(request.messages);
452
475
  let turn;
453
- if (request.stream && (request.tools.length > 0 || toolResult)) {
454
- event.res.status = 400;
455
- return openAIError("Streaming tool calls are not supported.", "invalid_request_error");
456
- }
457
476
  if (toolResult) {
458
477
  const pendingTurn = pendingTurns.get(toolResult.toolCallId);
459
478
  if (!pendingTurn || !pendingTurn.resume(request, toolResult)) {
@@ -490,25 +509,20 @@ function createGateway(options) {
490
509
  activeTurns.add(turn);
491
510
  turn.start();
492
511
  }
493
- if (request.stream) return streamCompletion(turn, model.id);
512
+ if (request.stream) return streamCompletion(turn, model.id, options.onCompatibilityDiagnostic);
494
513
  let output;
495
514
  try {
496
- output = await turn.nextOutput(event.req.signal);
515
+ output = await bufferCompletion(turn, event.req.signal);
497
516
  } catch {
498
517
  event.res.status = 500;
499
518
  return openAIError("The ACP agent could not complete the request.", "server_error");
500
519
  }
501
520
  if (output.type === "tool-call") {
502
- turn.armContinuation(output.call.id);
503
- options.onCompatibilityDiagnostic?.({
504
- field: "usage",
505
- message: "ACP usage is unavailable while a deferred tool call is pending; zero usage was reported.",
506
- value: null
507
- });
521
+ deferToolCall(turn, output.call, options.onCompatibilityDiagnostic);
508
522
  return chatCompletion(turn.modelId, {
509
523
  finishReason: "tool_calls",
510
524
  message: {
511
- content: null,
525
+ content: output.text || null,
512
526
  role: "assistant",
513
527
  tool_calls: [{
514
528
  function: {
@@ -519,13 +533,8 @@ function createGateway(options) {
519
533
  type: "function"
520
534
  }]
521
535
  }
522
- }, {
523
- inputTokens: 0,
524
- outputTokens: 0,
525
- totalTokens: 0
526
- });
536
+ }, zeroUsage);
527
537
  }
528
- if (output.type === "text-delta") throw new Error("Text deltas are only emitted for streaming requests.");
529
538
  return chatCompletion(turn.modelId, {
530
539
  finishReason: finishReason(output.stopReason),
531
540
  message: {
@@ -620,6 +629,20 @@ function authorizeRequestTools(client) {
620
629
  return { outcome: { outcome: "cancelled" } };
621
630
  });
622
631
  }
632
+ async function bufferCompletion(turn, signal) {
633
+ let text = "";
634
+ for (;;) {
635
+ const output = await turn.nextOutput(signal);
636
+ if (output.type === "text-delta") {
637
+ text += output.text;
638
+ continue;
639
+ }
640
+ return {
641
+ ...output,
642
+ text
643
+ };
644
+ }
645
+ }
623
646
  function chatCompletion(model, choice, usage) {
624
647
  return {
625
648
  choices: [{
@@ -631,11 +654,7 @@ function chatCompletion(model, choice, usage) {
631
654
  id: `chatcmpl-${crypto.randomUUID()}`,
632
655
  model,
633
656
  object: "chat.completion",
634
- ...usage ? { usage: {
635
- completion_tokens: usage.outputTokens,
636
- prompt_tokens: usage.inputTokens,
637
- total_tokens: usage.totalTokens
638
- } } : {}
657
+ ...usage ? { usage: toOpenAIUsage(usage) } : {}
639
658
  };
640
659
  }
641
660
  function chatCompletionChunk(id, created, model, choices) {
@@ -664,6 +683,14 @@ function chatCompletionRequest(value) {
664
683
  function connectWith(client, connection, operation) {
665
684
  return connection.kind === "agent-app" ? client.connectWith(connection.agent, operation) : client.connectWith(connection.stream, operation);
666
685
  }
686
+ function deferToolCall(turn, call, onCompatibilityDiagnostic) {
687
+ turn.armContinuation(call.id);
688
+ onCompatibilityDiagnostic?.({
689
+ field: "usage",
690
+ message: "ACP usage is unavailable while a deferred tool call is pending; zero usage was reported.",
691
+ value: null
692
+ });
693
+ }
667
694
  function enqueueSse(controller, encoder, value) {
668
695
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
669
696
  }
@@ -704,11 +731,12 @@ function openAIError(message, type) {
704
731
  }
705
732
  function renderMessages(request) {
706
733
  const requiredTool = request.toolChoice && typeof request.toolChoice === "object" ? request.toolChoice.function.name : void 0;
734
+ const toolNames = request.tools.map((tool) => tool.function.name).join(", ");
707
735
  return [
708
736
  "Process this OpenAI chat transcript. Preserve message order and role intent.",
709
737
  "Return only the assistant response requested by the transcript.",
710
- request.toolChoice === "required" ? "You must call one of the supplied tools before answering." : "",
711
- requiredTool ? `You must call the ${JSON.stringify(requiredTool)} tool before answering.` : "",
738
+ request.toolChoice === "required" ? `You must call one of these request-scoped MCP tools before answering: ${toolNames}.` : "",
739
+ requiredTool ? `You must call the request-scoped MCP tool ${JSON.stringify(requiredTool)} before answering.` : "",
712
740
  "",
713
741
  JSON.stringify({ messages: request.messages })
714
742
  ].join("\n");
@@ -727,10 +755,23 @@ function sessionExtensions(extensions) {
727
755
  }
728
756
  };
729
757
  }
730
- function streamCompletion(turn, model) {
758
+ function streamCompletion(turn, model, onCompatibilityDiagnostic) {
731
759
  const encoder = new TextEncoder();
732
760
  const id = `chatcmpl-${crypto.randomUUID()}`;
733
761
  const created = Math.floor(Date.now() / 1e3);
762
+ const finishStream = (controller, reason, usage) => {
763
+ enqueueSse(controller, encoder, chatCompletionChunk(id, created, model, [{
764
+ delta: {},
765
+ finish_reason: reason,
766
+ index: 0
767
+ }]));
768
+ if (usage) enqueueSse(controller, encoder, {
769
+ ...chatCompletionChunk(id, created, model, []),
770
+ usage: toOpenAIUsage(usage)
771
+ });
772
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
773
+ controller.close();
774
+ };
734
775
  const body = new ReadableStream({
735
776
  async cancel() {
736
777
  await turn.cancel(/* @__PURE__ */ new Error("The OpenAI stream consumer cancelled the request."));
@@ -749,22 +790,27 @@ function streamCompletion(turn, model) {
749
790
  }]));
750
791
  continue;
751
792
  }
752
- if (output.type === "tool-call") throw new Error("Streaming tool calls are not supported.");
753
- enqueueSse(controller, encoder, chatCompletionChunk(id, created, model, [{
754
- delta: {},
755
- finish_reason: finishReason(output.stopReason),
756
- index: 0
757
- }]));
758
- if (output.usage) enqueueSse(controller, encoder, {
759
- ...chatCompletionChunk(id, created, model, []),
760
- usage: {
761
- completion_tokens: output.usage.outputTokens,
762
- prompt_tokens: output.usage.inputTokens,
763
- total_tokens: output.usage.totalTokens
764
- }
765
- });
766
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
767
- controller.close();
793
+ if (output.type === "tool-call") {
794
+ deferToolCall(turn, output.call, onCompatibilityDiagnostic);
795
+ enqueueSse(controller, encoder, chatCompletionChunk(id, created, model, [{
796
+ delta: {
797
+ role: "assistant",
798
+ tool_calls: [{
799
+ function: {
800
+ arguments: output.call.arguments,
801
+ name: output.call.name
802
+ },
803
+ id: output.call.id,
804
+ index: 0,
805
+ type: "function"
806
+ }]
807
+ },
808
+ index: 0
809
+ }]));
810
+ finishStream(controller, "tool_calls", zeroUsage);
811
+ return;
812
+ }
813
+ finishStream(controller, finishReason(output.stopReason), output.usage);
768
814
  return;
769
815
  }
770
816
  } catch (error) {
@@ -782,5 +828,12 @@ function toolsForChoice(tools, choice) {
782
828
  if (typeof choice === "object") return tools.filter((tool) => tool.function.name === choice.function.name);
783
829
  return tools;
784
830
  }
831
+ function toOpenAIUsage(usage) {
832
+ return {
833
+ completion_tokens: usage.outputTokens,
834
+ prompt_tokens: usage.inputTokens,
835
+ total_tokens: usage.totalTokens
836
+ };
837
+ }
785
838
  //#endregion
786
839
  export { createCommandModel, createGateway, startGateway };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alint-js/model-adapter-acp",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.mts",
@@ -21,7 +21,7 @@
21
21
  "zod": "^4.4.3"
22
22
  },
23
23
  "devDependencies": {
24
- "@alint-js/core": "0.4.0"
24
+ "@alint-js/core": "0.5.0"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsdown",