@ai-sdk/harness-cline 1.0.26 → 1.0.28

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": "@ai-sdk/harness-cline",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.99",
29
+ "@ai-sdk/harness": "1.0.101",
30
30
  "@ai-sdk/provider": "4.0.10",
31
31
  "@ai-sdk/provider-utils": "5.0.36",
32
32
  "@cline/agents": "^0.0.79",
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  commonTool,
3
+ HARNESS_V1_BUILTIN_TOOLS,
3
4
  type HarnessV1,
4
5
  type HarnessV1BuiltinTool,
5
6
  } from '@ai-sdk/harness';
@@ -70,6 +71,11 @@ export type ClineHarnessSettings = {
70
71
  * `cline-tools.ts` — keep the two in sync.
71
72
  */
72
73
  const CLINE_BUILTIN_TOOLS = {
74
+ askUserQuestions: {
75
+ ...HARNESS_V1_BUILTIN_TOOLS.askUserQuestions,
76
+ nativeName: 'ask_question',
77
+ toolUseKind: 'readonly',
78
+ },
73
79
  read: commonTool('read', {
74
80
  nativeName: 'read',
75
81
  toolUseKind: 'readonly',
@@ -7,6 +7,7 @@ import {
7
7
  type HarnessV1PermissionMode,
8
8
  type HarnessV1PromptControl,
9
9
  type HarnessV1PromptTurnOptions,
10
+ type HarnessV1QuestionsToolOutput,
10
11
  type HarnessV1ResumeSessionState,
11
12
  type HarnessV1Session,
12
13
  type HarnessV1Skill,
@@ -45,8 +46,12 @@ import {
45
46
  CLINE_NATIVE_TOOL_KINDS,
46
47
  createClineToolResult,
47
48
  isClineBuiltinToolName,
49
+ clineQuestionKey,
48
50
  resolveActiveClineBuiltinNames,
49
51
  type PendingToolResult,
52
+ type PendingClineQuestion,
53
+ type PendingClineQuestionResult,
54
+ toClineQuestionResult,
50
55
  unwrapClineToolResult,
51
56
  } from './cline-tools';
52
57
  import {
@@ -320,6 +325,8 @@ export async function createClineSession(
320
325
  // Emit channel set at the start of every turn and cleared on end.
321
326
  let currentEmit: ((part: HarnessV1StreamPart) => void) | undefined;
322
327
  let translatorState: ClineTranslatorState | undefined;
328
+ const pendingQuestions = new Map<string, PendingClineQuestion>();
329
+ const pendingQuestionResults = new Map<string, PendingClineQuestionResult>();
323
330
  let activeTurn: ActiveClineTurn | undefined;
324
331
 
325
332
  function settlePendingToolResults(reason: string): void {
@@ -355,6 +362,14 @@ export async function createClineSession(
355
362
  pendingUserMessages.length = 0;
356
363
  }
357
364
 
365
+ function settlePendingQuestions(reason: string): void {
366
+ for (const pending of pendingQuestions.values()) {
367
+ pending.resolve(reason);
368
+ }
369
+ pendingQuestions.clear();
370
+ pendingQuestionResults.clear();
371
+ }
372
+
358
373
  async function persistHistory(): Promise<void> {
359
374
  const messages = agent?.snapshot().messages ?? currentMessages;
360
375
  await persistHistoryToSandbox({
@@ -414,7 +429,12 @@ export async function createClineSession(
414
429
  ? { maxIterations: input.settings.maxIterations }
415
430
  : {}),
416
431
  tools: [
417
- ...buildBuiltinAgentTools({ ops, activeNames: activeBuiltinNames }),
432
+ ...buildBuiltinAgentTools({
433
+ ops,
434
+ activeNames: activeBuiltinNames,
435
+ pendingQuestions,
436
+ pendingQuestionResults,
437
+ }),
418
438
  ...(rebuildInput.skillsRuntime.tool &&
419
439
  activeBuiltinNames.includes('skills')
420
440
  ? [rebuildInput.skillsRuntime.tool]
@@ -505,6 +525,7 @@ export async function createClineSession(
505
525
  // executions do not dangle, then abort the loop itself.
506
526
  settlePendingToolResults('Turn aborted');
507
527
  settlePendingToolApprovals('Turn aborted');
528
+ settlePendingQuestions('The question was cancelled.');
508
529
  agent?.abort('Turn aborted by caller');
509
530
  };
510
531
  if (controlInput.abortSignal) {
@@ -523,6 +544,26 @@ export async function createClineSession(
523
544
 
524
545
  return {
525
546
  async submitToolResult(args) {
547
+ const pendingQuestion = pendingQuestions.get(args.toolCallId);
548
+ if (pendingQuestion != null) {
549
+ pendingQuestions.delete(args.toolCallId);
550
+ pendingQuestion.resolve(
551
+ toClineQuestionResult({
552
+ nativeInput: pendingQuestion.input,
553
+ output: args.output as HarnessV1QuestionsToolOutput,
554
+ }),
555
+ );
556
+ return;
557
+ }
558
+ if (args.toolResult?.toolName === 'askUserQuestions') {
559
+ const nativeRequest = args.toolResult.providerOptions?.cline
560
+ ?.nativeRequest as PendingClineQuestion['input'] | undefined;
561
+ if (nativeRequest == null) return;
562
+ pendingQuestionResults.set(clineQuestionKey(nativeRequest), {
563
+ output: args.output as HarnessV1QuestionsToolOutput,
564
+ });
565
+ return;
566
+ }
526
567
  const pending = pendingToolResults.get(args.toolCallId);
527
568
  if (!pending) return;
528
569
  pendingToolResults.delete(args.toolCallId);
@@ -784,6 +825,7 @@ export async function createClineSession(
784
825
  parkedClineSessions.delete(input.sessionId);
785
826
  settlePendingToolResults('Cline session stopped');
786
827
  settlePendingToolApprovals('Cline session stopped');
828
+ settlePendingQuestions('The question was cancelled.');
787
829
 
788
830
  // Persist the conversation into the sandbox so a future process can pick
789
831
  // it up after the sandbox provider reattaches.
@@ -889,7 +931,11 @@ export async function createClineSession(
889
931
  doStop,
890
932
 
891
933
  doDetach: async (): Promise<HarnessV1ResumeSessionState> => {
892
- if (activeTurn != null || pendingToolResults.size > 0) {
934
+ if (
935
+ activeTurn != null ||
936
+ pendingToolResults.size > 0 ||
937
+ pendingQuestions.size > 0
938
+ ) {
893
939
  parkedClineSessions.set(input.sessionId, sessionImpl);
894
940
  try {
895
941
  await persistHistory();
@@ -916,7 +962,9 @@ export async function createClineSession(
916
962
  }
917
963
  if (
918
964
  activeTurn != null &&
919
- (pendingToolResults.size > 0 || pendingToolApprovals.size > 0)
965
+ (pendingToolResults.size > 0 ||
966
+ pendingToolApprovals.size > 0 ||
967
+ pendingQuestions.size > 0)
920
968
  ) {
921
969
  parkedClineSessions.set(input.sessionId, sessionImpl);
922
970
  try {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  HarnessV1BuiltinToolFiltering,
3
+ HarnessV1QuestionsToolOutput,
3
4
  HarnessV1ToolSpec,
4
5
  } from '@ai-sdk/harness';
5
6
  import {
@@ -7,6 +8,7 @@ import {
7
8
  type AgentTool,
8
9
  type AgentToolResult,
9
10
  } from '@cline/agents';
11
+ import { createDefaultTools } from '@cline/core';
10
12
  import type { ClineRemoteOps } from './cline-remote-ops';
11
13
  import { getErrorText } from './cline-utils';
12
14
 
@@ -17,6 +19,7 @@ import { getErrorText } from './cline-utils';
17
19
  * identical.
18
20
  */
19
21
  export const CLINE_NATIVE_BUILTIN_NAMES = [
22
+ 'ask_question',
20
23
  'read',
21
24
  'write',
22
25
  'edit',
@@ -33,6 +36,7 @@ export type ClineNativeBuiltinName =
33
36
  export const CLINE_NATIVE_TOOL_KINDS: Readonly<
34
37
  Record<ClineNativeBuiltinName, 'readonly' | 'edit' | 'bash'>
35
38
  > = {
39
+ ask_question: 'readonly',
36
40
  read: 'readonly',
37
41
  write: 'edit',
38
42
  edit: 'edit',
@@ -58,13 +62,16 @@ export function resolveActiveClineBuiltinNames(
58
62
  toolFiltering: HarnessV1BuiltinToolFiltering | undefined,
59
63
  ): ReadonlyArray<ClineNativeBuiltinName> {
60
64
  if (toolFiltering == null) return CLINE_NATIVE_BUILTIN_NAMES;
65
+ const filteredNames = toolFiltering.toolNames.map(name =>
66
+ name === 'askUserQuestions' ? 'ask_question' : name,
67
+ );
61
68
  if (toolFiltering.mode === 'allow') {
62
69
  return CLINE_NATIVE_BUILTIN_NAMES.filter(name =>
63
- toolFiltering.toolNames.includes(name),
70
+ filteredNames.includes(name),
64
71
  );
65
72
  }
66
73
  return CLINE_NATIVE_BUILTIN_NAMES.filter(
67
- name => !toolFiltering.toolNames.includes(name),
74
+ name => !filteredNames.includes(name),
68
75
  );
69
76
  }
70
77
 
@@ -72,6 +79,25 @@ export interface PendingToolResult {
72
79
  resolve: (value: unknown) => void;
73
80
  }
74
81
 
82
+ export interface PendingClineQuestion {
83
+ readonly input: {
84
+ readonly question: string;
85
+ readonly options: readonly string[];
86
+ };
87
+ resolve: (value: string) => void;
88
+ }
89
+
90
+ export interface PendingClineQuestionResult {
91
+ readonly output: HarnessV1QuestionsToolOutput;
92
+ }
93
+
94
+ export function clineQuestionKey(input: {
95
+ readonly question: string;
96
+ readonly options: readonly string[];
97
+ }): string {
98
+ return JSON.stringify([input.question, input.options]);
99
+ }
100
+
75
101
  const clineToolResultMarker = Symbol('cline-tool-result');
76
102
 
77
103
  interface ClineToolResult {
@@ -136,12 +162,50 @@ async function guarded<T>(run: () => Promise<T>): Promise<ClineToolResult> {
136
162
  export function buildBuiltinAgentTools({
137
163
  ops,
138
164
  activeNames,
165
+ pendingQuestions = new Map(),
166
+ pendingQuestionResults = new Map(),
139
167
  }: {
140
168
  ops: ClineRemoteOps;
141
169
  activeNames: ReadonlyArray<ClineNativeBuiltinName>;
170
+ pendingQuestions?: Map<string, PendingClineQuestion>;
171
+ pendingQuestionResults?: Map<string, PendingClineQuestionResult>;
142
172
  }): AgentTool<any, any>[] {
143
173
  // biome/eslint: `any` matches the runtime's own `tools?: readonly AgentTool<any, any>[]`.
144
174
  const tools: Partial<Record<ClineNativeBuiltinName, AgentTool<any, any>>> = {
175
+ ask_question: createDefaultTools({
176
+ executors: {
177
+ askQuestion: async (question, options, context) => {
178
+ if (context.toolCallId == null) {
179
+ throw new Error(
180
+ 'Cline ask_question did not provide a tool call id.',
181
+ );
182
+ }
183
+ const questionKey = clineQuestionKey({ question, options });
184
+ const pendingResult = pendingQuestionResults.get(questionKey);
185
+ if (pendingResult != null) {
186
+ pendingQuestionResults.delete(questionKey);
187
+ return toClineQuestionResult({
188
+ nativeInput: { question, options },
189
+ output: pendingResult.output,
190
+ });
191
+ }
192
+ return new Promise<string>(resolve => {
193
+ pendingQuestions.set(context.toolCallId!, {
194
+ input: { question, options },
195
+ resolve,
196
+ });
197
+ });
198
+ },
199
+ },
200
+ enableReadFiles: false,
201
+ enableSearch: false,
202
+ enableBash: false,
203
+ enableWebFetch: false,
204
+ enableApplyPatch: false,
205
+ enableEditor: false,
206
+ enableSkills: false,
207
+ enableAskQuestion: true,
208
+ }).find(tool => tool.name === 'ask_question'),
145
209
  read: createTool({
146
210
  name: 'read',
147
211
  description: 'Read file contents.',
@@ -325,6 +389,29 @@ export function buildBuiltinAgentTools({
325
389
  .filter((tool): tool is AgentTool<any, any> => tool != null);
326
390
  }
327
391
 
392
+ export function toClineQuestionResult(input: {
393
+ nativeInput: PendingClineQuestion['input'];
394
+ output: HarnessV1QuestionsToolOutput;
395
+ }): string {
396
+ if (
397
+ input.output.action === 'declined' ||
398
+ input.output.action === 'cancelled'
399
+ ) {
400
+ return `The user ${input.output.action} the question.`;
401
+ }
402
+ const answer = input.output.answers['question-1'];
403
+ if (answer == null) return 'The user did not answer the question.';
404
+ if (answer.freeform != null) return answer.freeform;
405
+ const result = answer.optionIds
406
+ .map(id => {
407
+ const index = Number(id.replace(/^option-/, '')) - 1;
408
+ return input.nativeInput.options[index];
409
+ })
410
+ .filter((value): value is string => value != null)
411
+ .join(', ');
412
+ return result;
413
+ }
414
+
328
415
  /**
329
416
  * Build host-executed user tools. When the model calls one, the tool parks a
330
417
  * promise keyed by the runtime's `toolCallId`; the harness caller supplies the
@@ -78,6 +78,38 @@ function toolCallPart(
78
78
  if (isMcpTool) {
79
79
  state.dynamicToolCallIds.add(toolCall.toolCallId);
80
80
  }
81
+ if (toolCall.toolName === 'ask_question') {
82
+ const nativeInput = toolCall.input as {
83
+ question: string;
84
+ options: string[];
85
+ };
86
+ return {
87
+ type: 'tool-call',
88
+ toolCallId: toolCall.toolCallId,
89
+ toolName: 'askUserQuestions',
90
+ input: JSON.stringify({
91
+ allowPartialAnswers: false,
92
+ questions: [
93
+ {
94
+ id: 'question-1',
95
+ question: nativeInput.question,
96
+ options: nativeInput.options.map((label, index) => ({
97
+ id: `option-${index + 1}`,
98
+ label,
99
+ })),
100
+ allowFreeForm: true,
101
+ },
102
+ ],
103
+ }),
104
+ providerExecuted: false,
105
+ providerMetadata: {
106
+ cline: {
107
+ nativeRequest: nativeInput,
108
+ },
109
+ },
110
+ nativeName: 'ask_question',
111
+ };
112
+ }
81
113
  return {
82
114
  type: 'tool-call',
83
115
  toolCallId: toolCall.toolCallId,
@@ -209,7 +241,10 @@ export function translateClineEvent(
209
241
  {
210
242
  type: 'tool-result',
211
243
  toolCallId: event.toolCall.toolCallId,
212
- toolName: event.toolCall.toolName,
244
+ toolName:
245
+ event.toolCall.toolName === 'ask_question'
246
+ ? 'askUserQuestions'
247
+ : event.toolCall.toolName,
213
248
  result: toToolResultValue(output),
214
249
  ...(resultPart?.isError ? { isError: true } : {}),
215
250
  ...(dynamic ? { dynamic: true } : {}),