@sayknow-cli/coding-agent 0.5.10 → 0.5.13
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/CHANGELOG.md +42 -0
- package/dist/types/config/file-lock.d.ts +8 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/session-import/redact.d.ts +1 -1
- package/dist/types/skc-runtime/ultragoal-runtime.d.ts +13 -1
- package/dist/types/skc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/skc-runtime/workflow-placeholder.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +460 -172
- package/package.json +7 -7
- package/src/cli/telegram-cli.ts +1 -1
- package/src/config/file-lock-gc.ts +39 -2
- package/src/config/file-lock.ts +22 -1
- package/src/config/model-profile-activation.ts +7 -11
- package/src/config/model-registry.ts +192 -44
- package/src/config/model-resolver.ts +4 -2
- package/src/config/models-config-schema.ts +1 -0
- package/src/defaults/skc/skills/deep-interview/SKILL.md +12 -0
- package/src/defaults/skc/skills/ultragoal/SKILL.md +3 -0
- package/src/modes/components/settings-selector.ts +39 -21
- package/src/modes/controllers/selector-controller.ts +35 -15
- package/src/modes/rpc/rpc-client.ts +20 -4
- package/src/sdk/bus/index.ts +1 -3
- package/src/session-import/redact.ts +6 -3
- package/src/skc-runtime/state-writer.ts +24 -27
- package/src/skc-runtime/ultragoal-runtime.ts +125 -45
- package/src/skc-runtime/ultragoal-succession.ts +1667 -0
- package/src/skc-runtime/workflow-placeholder.ts +33 -0
- package/src/tools/ask.ts +366 -108
- package/src/tools/bisect.ts +4 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Shared placeholder semantics for model-authored workflow content. */
|
|
2
|
+
|
|
3
|
+
const WORKFLOW_PLACEHOLDER_TOKENS = new Set([
|
|
4
|
+
"empty",
|
|
5
|
+
"n/a",
|
|
6
|
+
"n-a",
|
|
7
|
+
"na",
|
|
8
|
+
"none",
|
|
9
|
+
"placeholder",
|
|
10
|
+
"stub",
|
|
11
|
+
"tbd",
|
|
12
|
+
"todo",
|
|
13
|
+
"unused",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const WORKFLOW_PLACEHOLDER_CORRECTION =
|
|
17
|
+
"provide a specific, non-placeholder question or objective instead of empty, whitespace, unused, TODO, TBD, N/A, N-A, NA, none, placeholder, or stub";
|
|
18
|
+
|
|
19
|
+
/** Exact, case-insensitive placeholder detection; meaningful sentences remain valid. */
|
|
20
|
+
export function isWorkflowPlaceholderText(value: unknown): boolean {
|
|
21
|
+
if (typeof value !== "string") return false;
|
|
22
|
+
const normalized = value.trim().toLowerCase();
|
|
23
|
+
return normalized.length === 0 || WORKFLOW_PLACEHOLDER_TOKENS.has(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Detect a legacy formatted deep-interview ask whose visible body is a placeholder. */
|
|
27
|
+
export function isLegacyDeepInterviewPlaceholder(value: unknown): boolean {
|
|
28
|
+
if (typeof value !== "string") return false;
|
|
29
|
+
const lines = value.trim().split(/\r?\n/);
|
|
30
|
+
const headerIndex = lines.findIndex(line => /^Round\s+\d+\s+\|.*\bAmbiguity\b/i.test(line.trim()));
|
|
31
|
+
if (headerIndex < 0) return false;
|
|
32
|
+
return isWorkflowPlaceholderText(lines.slice(headerIndex + 1).join("\n"));
|
|
33
|
+
}
|
package/src/tools/ask.ts
CHANGED
|
@@ -41,7 +41,14 @@ import { appendOrMergeDeepInterviewRound, syncDeepInterviewRecorderHud } from ".
|
|
|
41
41
|
import { deepInterviewStatePath } from "../skc-runtime/deep-interview-runtime";
|
|
42
42
|
import { DEEP_INTERVIEW_INTENT_CATEGORIES, deepInterviewCharacterCount } from "../skc-runtime/deep-interview-state";
|
|
43
43
|
import { renderStatusLine } from "../tui";
|
|
44
|
-
import type {
|
|
44
|
+
import type {
|
|
45
|
+
AskAnswerRequest,
|
|
46
|
+
AskRemoteInteraction,
|
|
47
|
+
AskRemoteReceipt,
|
|
48
|
+
AskSettlement,
|
|
49
|
+
AskSettlementResult,
|
|
50
|
+
ToolSession,
|
|
51
|
+
} from ".";
|
|
45
52
|
import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
|
|
46
53
|
import { ToolAbortError } from "./tool-errors";
|
|
47
54
|
import { assertUltragoalAskAllowed } from "./ultragoal-ask-guard";
|
|
@@ -134,50 +141,83 @@ const deepInterviewMetaBase = {
|
|
|
134
141
|
* only exists after Round 0 has locked a manifest. Providers that only see the
|
|
135
142
|
* JSON schema must be unable to combine the two.
|
|
136
143
|
*/
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
144
|
+
const DeepInterviewTopologyMeta = z
|
|
145
|
+
.object({
|
|
146
|
+
...deepInterviewMetaBase,
|
|
147
|
+
round: z.literal(0).describe("the manifest is locked exactly once, at Round 0"),
|
|
148
|
+
component: z.literal("review-topology"),
|
|
149
|
+
intent_contract: IntentContract,
|
|
150
|
+
})
|
|
151
|
+
.strict()
|
|
152
|
+
.describe("Round 0 topology confirmation carrying the locked-intent manifest proposal");
|
|
153
|
+
const DeepInterviewReviewMeta = z
|
|
154
|
+
.object({
|
|
155
|
+
...deepInterviewMetaBase,
|
|
156
|
+
round: z.number().int().min(1).describe("reviews only exist after Round 0 locked a manifest"),
|
|
157
|
+
intent_review: IntentReview,
|
|
158
|
+
})
|
|
159
|
+
.strict()
|
|
160
|
+
.describe("post-Round-0 locked-intent reduction review");
|
|
161
|
+
const DeepInterviewRoundMeta = z.object(deepInterviewMetaBase).strict().describe("ordinary interview round metadata");
|
|
162
|
+
// Branch order is load-bearing: the intent-carrying branches must win when
|
|
163
|
+
// their key is present, and every branch is strict so a contract or review
|
|
164
|
+
// attached to the wrong round fails closed instead of being silently
|
|
165
|
+
// stripped by a laxer branch.
|
|
166
|
+
const DeepInterviewMeta = z.union([DeepInterviewTopologyMeta, DeepInterviewReviewMeta, DeepInterviewRoundMeta]);
|
|
167
|
+
type DeepInterviewMeta = z.infer<typeof DeepInterviewMeta>;
|
|
161
168
|
|
|
162
169
|
const WorkflowGateMeta = z.object({
|
|
163
170
|
stage: z.enum(["deep-interview", "ralplan", "ultragoal"]).describe("workflow gate stage"),
|
|
164
171
|
kind: z.enum(["question", "approval", "execution"]).describe("workflow gate kind"),
|
|
165
172
|
});
|
|
166
173
|
|
|
167
|
-
|
|
174
|
+
function createQuestionItemSchema(deepInterviewSchema: z.ZodType<DeepInterviewMeta>) {
|
|
175
|
+
return z.object({
|
|
176
|
+
id: z.string().describe("question id"),
|
|
177
|
+
question: z.string().describe("question text"),
|
|
178
|
+
options: z.array(OptionItem).describe("available options"),
|
|
179
|
+
multi: z.boolean().describe("allow multiple selections").optional(),
|
|
180
|
+
recommended: z.number().describe("recommended option index").optional(),
|
|
181
|
+
workflowGate: WorkflowGateMeta.describe("optional workflow gate stage/kind override").optional(),
|
|
182
|
+
deepInterview: deepInterviewSchema.describe("optional deep-interview round metadata").optional(),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const QuestionItem = createQuestionItemSchema(DeepInterviewMeta);
|
|
187
|
+
/** Round 0 of an active deep interview: only the topology confirmation may carry intent authority. */
|
|
188
|
+
const TopologyQuestionItem = createQuestionItemSchema(DeepInterviewTopologyMeta);
|
|
189
|
+
/** After Round 0 locked a manifest: ordinary rounds and reduction reviews, never a new contract. */
|
|
190
|
+
const PostTopologyQuestionItem = createQuestionItemSchema(z.union([DeepInterviewRoundMeta, DeepInterviewReviewMeta]));
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Outside an active deep interview no deep-interview authority exists, so the
|
|
194
|
+
* provider-facing schema omits the metadata entirely and validation strips it.
|
|
195
|
+
*/
|
|
196
|
+
const OrdinaryQuestionItem = z.object({
|
|
168
197
|
id: z.string().describe("question id"),
|
|
169
198
|
question: z.string().describe("question text"),
|
|
170
199
|
options: z.array(OptionItem).describe("available options"),
|
|
171
200
|
multi: z.boolean().describe("allow multiple selections").optional(),
|
|
172
201
|
recommended: z.number().describe("recommended option index").optional(),
|
|
173
202
|
workflowGate: WorkflowGateMeta.describe("optional workflow gate stage/kind override").optional(),
|
|
174
|
-
deepInterview: DeepInterviewMeta.describe("optional deep-interview round metadata").optional(),
|
|
175
203
|
});
|
|
176
204
|
|
|
177
205
|
export const askSchema = z.object({
|
|
178
206
|
questions: z.array(QuestionItem).min(1).describe("questions to ask"),
|
|
179
207
|
});
|
|
180
208
|
|
|
209
|
+
const topologyAskSchema = z.object({
|
|
210
|
+
questions: z.array(TopologyQuestionItem).min(1).describe("questions to ask"),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const postTopologyAskSchema = z.object({
|
|
214
|
+
questions: z.array(PostTopologyQuestionItem).min(1).describe("questions to ask"),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const ordinaryAskSchema = z.object({
|
|
218
|
+
questions: z.array(OrdinaryQuestionItem).min(1).describe("questions to ask"),
|
|
219
|
+
});
|
|
220
|
+
|
|
181
221
|
export type AskToolInput = z.infer<typeof askSchema>;
|
|
182
222
|
|
|
183
223
|
/** Result for a single question */
|
|
@@ -208,6 +248,31 @@ export interface AskToolDetails {
|
|
|
208
248
|
|
|
209
249
|
const OTHER_OPTION = "Other (type your own)";
|
|
210
250
|
const RECOMMENDED_SUFFIX = " (Recommended)";
|
|
251
|
+
|
|
252
|
+
/** A one-shot local receipt used to normalize legacy string answer sources. */
|
|
253
|
+
function legacyAskReceipt(value: string): {
|
|
254
|
+
source: "remote";
|
|
255
|
+
interaction: AskRemoteInteraction;
|
|
256
|
+
settle(settlement: AskSettlement): Promise<AskSettlementResult>;
|
|
257
|
+
} {
|
|
258
|
+
let settled: Promise<AskSettlementResult> | undefined;
|
|
259
|
+
return {
|
|
260
|
+
interaction: { kind: "value", value },
|
|
261
|
+
source: "remote",
|
|
262
|
+
settle(settlement) {
|
|
263
|
+
if (!settled) {
|
|
264
|
+
settled = Promise.resolve(
|
|
265
|
+
settlement.kind === "commit"
|
|
266
|
+
? { kind: "committed", ack: { status: "failed", reason: "unsupported" } }
|
|
267
|
+
: settlement.kind === "invalid"
|
|
268
|
+
? { kind: "invalid_closed" }
|
|
269
|
+
: { kind: "resolved_without_commit" },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return settled;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
211
276
|
const DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS = Number.MAX_SAFE_INTEGER;
|
|
212
277
|
const DEEP_INTERVIEW_RECORDER_AWAIT_TIMEOUT_MS = 250;
|
|
213
278
|
|
|
@@ -581,6 +646,11 @@ function formatQuestionResult(result: QuestionResult): string {
|
|
|
581
646
|
// =============================================================================
|
|
582
647
|
|
|
583
648
|
type AskParams = AskToolInput;
|
|
649
|
+
type AskParametersSchema =
|
|
650
|
+
| typeof ordinaryAskSchema
|
|
651
|
+
| typeof askSchema
|
|
652
|
+
| typeof topologyAskSchema
|
|
653
|
+
| typeof postTopologyAskSchema;
|
|
584
654
|
|
|
585
655
|
/**
|
|
586
656
|
* Ask tool for interactive user prompting during execution.
|
|
@@ -588,12 +658,25 @@ type AskParams = AskToolInput;
|
|
|
588
658
|
* Allows gathering user preferences, clarifying instructions, and getting decisions
|
|
589
659
|
* on implementation choices as the agent works.
|
|
590
660
|
*/
|
|
591
|
-
export class AskTool implements AgentTool<
|
|
661
|
+
export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
|
|
592
662
|
readonly name = "ask";
|
|
593
663
|
readonly label = "Ask";
|
|
594
664
|
readonly summary = "Ask the user a clarifying question";
|
|
595
665
|
readonly description: string;
|
|
596
|
-
|
|
666
|
+
/**
|
|
667
|
+
* Provider-facing metadata authority follows the durable deep-interview stage:
|
|
668
|
+
* Round 0 may lock an intent contract, later rounds may only review it, and a
|
|
669
|
+
* session with no active interview exposes (and accepts) no metadata at all.
|
|
670
|
+
* A host that does not track interview stages at all cannot fail closed on
|
|
671
|
+
* one, so it keeps the complete (still mutually exclusive) metadata union.
|
|
672
|
+
*/
|
|
673
|
+
get parameters(): AskParametersSchema {
|
|
674
|
+
if (typeof this.session.getDeepInterviewAskStage !== "function") return askSchema;
|
|
675
|
+
const stage = this.session.getDeepInterviewAskStage();
|
|
676
|
+
if (stage === "topology") return topologyAskSchema;
|
|
677
|
+
if (stage === "post-topology") return postTopologyAskSchema;
|
|
678
|
+
return ordinaryAskSchema;
|
|
679
|
+
}
|
|
597
680
|
readonly strict = true;
|
|
598
681
|
/**
|
|
599
682
|
* These fields are rendered to the user and carry no executable or durable
|
|
@@ -684,46 +767,157 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
684
767
|
}
|
|
685
768
|
|
|
686
769
|
const extensionUi = context?.ui;
|
|
770
|
+
let activeRemoteReceipt: AskRemoteReceipt | undefined;
|
|
771
|
+
let remoteGeneration = 0;
|
|
772
|
+
type RemoteRaceResult = {
|
|
773
|
+
winner: "remote";
|
|
774
|
+
value: string;
|
|
775
|
+
receipt: AskRemoteReceipt;
|
|
776
|
+
settlement?: AskSettlement;
|
|
777
|
+
};
|
|
778
|
+
const ignoreRemoteAnswerFailure = (error: unknown): Promise<never> => {
|
|
779
|
+
if (!(error instanceof Error && error.name === "AbortError")) {
|
|
780
|
+
logger.warn("Ask remote answer source failed", {
|
|
781
|
+
error: error instanceof Error ? error.message : String(error),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return new Promise<never>(() => {});
|
|
785
|
+
};
|
|
786
|
+
const settleActiveRemote = async (settlement: AskSettlement): Promise<void> => {
|
|
787
|
+
const receipt = activeRemoteReceipt;
|
|
788
|
+
activeRemoteReceipt = undefined;
|
|
789
|
+
if (receipt) await receipt.settle(settlement);
|
|
790
|
+
};
|
|
791
|
+
const checkedPrefix = `${theme.checkbox.checked} `;
|
|
792
|
+
const uncheckedPrefix = `${theme.checkbox.unchecked} `;
|
|
793
|
+
/** The option a displayed label stands for, without checkbox, numbering, or recommendation decoration. */
|
|
794
|
+
const semanticLabel = (label: string): string =>
|
|
795
|
+
stripRecommendedSuffix(
|
|
796
|
+
(label.startsWith(checkedPrefix)
|
|
797
|
+
? label.slice(checkedPrefix.length)
|
|
798
|
+
: label.startsWith(uncheckedPrefix)
|
|
799
|
+
? label.slice(uncheckedPrefix.length)
|
|
800
|
+
: label
|
|
801
|
+
).replace(/^\s*\d+[.)]\s+/, ""),
|
|
802
|
+
);
|
|
803
|
+
/** Turn a typed or legacy remote answer into a race result, or park forever when it cannot count. */
|
|
804
|
+
const acceptRemoteAnswer = (
|
|
805
|
+
answer: AskRemoteReceipt | string | undefined,
|
|
806
|
+
generation: number,
|
|
807
|
+
resolveValue: (receipt: AskRemoteReceipt, value: string) => RemoteRaceResult,
|
|
808
|
+
): RemoteRaceResult | Promise<RemoteRaceResult> => {
|
|
809
|
+
// undefined is not a valid remote answer (registration failed, or the local
|
|
810
|
+
// UI already won and aborted us): never settle the race, let the local
|
|
811
|
+
// selector decide instead of cancelling the ask.
|
|
812
|
+
if (answer === undefined) return new Promise<never>(() => {});
|
|
813
|
+
const receipt = typeof answer === "string" ? legacyAskReceipt(answer) : answer;
|
|
814
|
+
if (generation !== remoteGeneration) {
|
|
815
|
+
return receipt
|
|
816
|
+
.settle({ kind: "resolve_without_commit", reason: "aborted" })
|
|
817
|
+
.then(() => new Promise<never>(() => {}));
|
|
818
|
+
}
|
|
819
|
+
if (receipt.interaction.kind !== "value") {
|
|
820
|
+
// No remote controls are advertised, so a control interaction has nothing to
|
|
821
|
+
// act on: close it as invalid and keep waiting for a real answer.
|
|
822
|
+
return receipt
|
|
823
|
+
.settle({ kind: "invalid", reason: "invalid_control" })
|
|
824
|
+
.then(() => new Promise<never>(() => {}));
|
|
825
|
+
}
|
|
826
|
+
return resolveValue(receipt, receipt.interaction.value);
|
|
827
|
+
};
|
|
687
828
|
const ui: UIContext = {
|
|
688
829
|
select: (prompt, options, dialogOptions) => {
|
|
689
830
|
if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
|
|
690
831
|
const source = this.session.getAskAnswerSource?.();
|
|
691
832
|
if (!source) return extensionUi.select(prompt, options, dialogOptions);
|
|
692
833
|
// Race the local UI against a remote answer (e.g. a Telegram reply via the
|
|
693
|
-
// notifications SDK) so asks can be answered without RPC mode. When the
|
|
694
|
-
// local UI wins, abort the remote source so it stops waiting and marks the
|
|
695
|
-
// action resolved-locally. First valid answer wins.
|
|
696
|
-
// Race the local UI against a remote answer (e.g. a Telegram reply via the
|
|
697
834
|
// notifications SDK) so asks can be answered without RPC mode. First valid
|
|
698
835
|
// answer wins; the loser is aborted so neither side is left hanging:
|
|
699
836
|
// - local wins -> abort the remote source (marks the action resolved-locally)
|
|
700
837
|
// - remote wins -> abort the local selector so the TUI dialog actually closes
|
|
838
|
+
// Typed sources (`awaitAnswerRequest`) hand back a receipt that is settled once
|
|
839
|
+
// the answer is accepted, so SDK-routed interactions commit durably.
|
|
701
840
|
const remoteController = new AbortController();
|
|
702
841
|
const localController = new AbortController();
|
|
703
|
-
|
|
842
|
+
const generation = ++remoteGeneration;
|
|
843
|
+
// Propagate external cancellation to both race legs and invalidate late replies.
|
|
704
844
|
const toolSignal = dialogOptions?.signal;
|
|
845
|
+
const abortRace = () => {
|
|
846
|
+
if (generation === remoteGeneration) remoteGeneration++;
|
|
847
|
+
localController.abort();
|
|
848
|
+
remoteController.abort();
|
|
849
|
+
};
|
|
705
850
|
if (toolSignal) {
|
|
706
|
-
if (toolSignal.aborted)
|
|
707
|
-
else toolSignal.addEventListener("abort",
|
|
851
|
+
if (toolSignal.aborted) abortRace();
|
|
852
|
+
else toolSignal.addEventListener("abort", abortRace, { once: true });
|
|
708
853
|
}
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
854
|
+
const request: AskAnswerRequest = { question: prompt, options, interaction: "selector", controls: [] };
|
|
855
|
+
const remote = (
|
|
856
|
+
source.awaitAnswerRequest
|
|
857
|
+
? source.awaitAnswerRequest(request, remoteController.signal)
|
|
858
|
+
: source.awaitAnswer(prompt, options, remoteController.signal)
|
|
859
|
+
)
|
|
860
|
+
.then(answer =>
|
|
861
|
+
acceptRemoteAnswer(answer, generation, (receipt, remoteValue) => {
|
|
862
|
+
const selectedValue =
|
|
863
|
+
options.find(
|
|
864
|
+
option =>
|
|
865
|
+
option === remoteValue ||
|
|
866
|
+
option === `${checkedPrefix}${remoteValue}` ||
|
|
867
|
+
option === `${uncheckedPrefix}${remoteValue}`,
|
|
868
|
+
) ?? remoteValue;
|
|
869
|
+
if (semanticLabel(selectedValue) === OTHER_OPTION) {
|
|
870
|
+
// "Other" only transitions to the custom editor; the answer arrives there.
|
|
871
|
+
return {
|
|
872
|
+
winner: "remote" as const,
|
|
873
|
+
value: selectedValue,
|
|
874
|
+
receipt,
|
|
875
|
+
settlement: { kind: "resolve_without_commit", reason: "other_transition" },
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (
|
|
879
|
+
(selectedValue.startsWith(checkedPrefix) || selectedValue.startsWith(uncheckedPrefix)) &&
|
|
880
|
+
options.includes(selectedValue)
|
|
881
|
+
) {
|
|
882
|
+
// Multi-select: a listed entry toggles and the question is shown again.
|
|
883
|
+
return {
|
|
884
|
+
winner: "remote" as const,
|
|
885
|
+
value: selectedValue,
|
|
886
|
+
receipt,
|
|
887
|
+
settlement: { kind: "resolve_without_commit", reason: "toggle" },
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
return { winner: "remote" as const, value: selectedValue, receipt };
|
|
891
|
+
}),
|
|
892
|
+
)
|
|
893
|
+
.catch(ignoreRemoteAnswerFailure);
|
|
717
894
|
const local = extensionUi
|
|
718
895
|
.select(prompt, options, { ...dialogOptions, signal: localController.signal })
|
|
719
896
|
.then(answer => {
|
|
897
|
+
if (generation === remoteGeneration) remoteGeneration++;
|
|
720
898
|
remoteController.abort();
|
|
721
|
-
return answer;
|
|
899
|
+
return { winner: "local" as const, value: answer };
|
|
900
|
+
})
|
|
901
|
+
.catch(error => {
|
|
902
|
+
if (generation === remoteGeneration) remoteGeneration++;
|
|
903
|
+
remoteController.abort();
|
|
904
|
+
throw error;
|
|
722
905
|
});
|
|
723
906
|
// The losing selector may reject when aborted after the race already settled;
|
|
724
907
|
// swallow that so it is not an unhandled rejection (the race result is unaffected).
|
|
725
908
|
void local.catch(() => undefined);
|
|
726
|
-
return Promise.race([local, remote])
|
|
909
|
+
return Promise.race([local, remote]).then(async result => {
|
|
910
|
+
if (result.winner === "remote") {
|
|
911
|
+
localController.abort();
|
|
912
|
+
if (result.settlement) await result.receipt.settle(result.settlement);
|
|
913
|
+
else activeRemoteReceipt = result.receipt;
|
|
914
|
+
} else {
|
|
915
|
+
void remote.then(remoteResult =>
|
|
916
|
+
remoteResult.receipt.settle({ kind: "resolve_without_commit", reason: "aborted" }),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
return result.value;
|
|
920
|
+
});
|
|
727
921
|
},
|
|
728
922
|
editor: (title, prefill, dialogOptions, editorOptions) => {
|
|
729
923
|
if (!extensionUi) throw new ToolAbortError("Ask tool requires interactive mode");
|
|
@@ -734,24 +928,60 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
734
928
|
// reply) instead of blocking on the local-only editor. Mirrors `select`.
|
|
735
929
|
const remoteController = new AbortController();
|
|
736
930
|
const localController = new AbortController();
|
|
931
|
+
const generation = ++remoteGeneration;
|
|
737
932
|
const toolSignal = dialogOptions?.signal;
|
|
933
|
+
const abortRace = () => {
|
|
934
|
+
if (generation === remoteGeneration) remoteGeneration++;
|
|
935
|
+
localController.abort();
|
|
936
|
+
remoteController.abort();
|
|
937
|
+
};
|
|
738
938
|
if (toolSignal) {
|
|
739
|
-
if (toolSignal.aborted)
|
|
740
|
-
else toolSignal.addEventListener("abort",
|
|
939
|
+
if (toolSignal.aborted) abortRace();
|
|
940
|
+
else toolSignal.addEventListener("abort", abortRace, { once: true });
|
|
741
941
|
}
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
942
|
+
const request: AskAnswerRequest = {
|
|
943
|
+
question: title,
|
|
944
|
+
options: [],
|
|
945
|
+
interaction: "custom_editor",
|
|
946
|
+
controls: [],
|
|
947
|
+
};
|
|
948
|
+
const remote = (
|
|
949
|
+
source.awaitAnswerRequest
|
|
950
|
+
? source.awaitAnswerRequest(request, remoteController.signal)
|
|
951
|
+
: source.awaitAnswer(title, [], remoteController.signal)
|
|
952
|
+
)
|
|
953
|
+
.then(answer =>
|
|
954
|
+
acceptRemoteAnswer(answer, generation, (receipt, value) => ({
|
|
955
|
+
winner: "remote" as const,
|
|
956
|
+
value,
|
|
957
|
+
receipt,
|
|
958
|
+
})),
|
|
959
|
+
)
|
|
960
|
+
.catch(ignoreRemoteAnswerFailure);
|
|
747
961
|
const local = extensionUi
|
|
748
962
|
.editor(title, prefill, { ...(dialogOptions ?? {}), signal: localController.signal }, editorOptions)
|
|
749
963
|
.then(answer => {
|
|
964
|
+
if (generation === remoteGeneration) remoteGeneration++;
|
|
750
965
|
remoteController.abort();
|
|
751
|
-
return answer;
|
|
966
|
+
return { winner: "local" as const, value: answer };
|
|
967
|
+
})
|
|
968
|
+
.catch(error => {
|
|
969
|
+
if (generation === remoteGeneration) remoteGeneration++;
|
|
970
|
+
remoteController.abort();
|
|
971
|
+
throw error;
|
|
752
972
|
});
|
|
753
973
|
void local.catch(() => undefined);
|
|
754
|
-
return Promise.race([local, remote])
|
|
974
|
+
return Promise.race([local, remote]).then(result => {
|
|
975
|
+
if (result.winner === "remote") {
|
|
976
|
+
activeRemoteReceipt = result.receipt;
|
|
977
|
+
localController.abort();
|
|
978
|
+
} else {
|
|
979
|
+
void remote.then(remoteResult =>
|
|
980
|
+
remoteResult.receipt.settle({ kind: "resolve_without_commit", reason: "aborted" }),
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
return result.value;
|
|
984
|
+
});
|
|
755
985
|
},
|
|
756
986
|
};
|
|
757
987
|
|
|
@@ -809,60 +1039,88 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
809
1039
|
timedOut: false,
|
|
810
1040
|
};
|
|
811
1041
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1042
|
+
for (;;)
|
|
1043
|
+
try {
|
|
1044
|
+
const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
|
|
1045
|
+
const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
|
|
1046
|
+
const displayQuestion = deepInterviewPrompt ?? q.question;
|
|
1047
|
+
const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
|
|
1048
|
+
const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
|
|
1049
|
+
const initialSelection =
|
|
1050
|
+
shouldNumberOptions && options?.previous
|
|
1051
|
+
? {
|
|
1052
|
+
...options.previous,
|
|
1053
|
+
selectedOptions: options.previous.selectedOptions.map(selected => {
|
|
1054
|
+
const rawIndex = rawOptionLabels.indexOf(selected);
|
|
1055
|
+
return rawIndex >= 0 ? (optionLabels[rawIndex] ?? selected) : selected;
|
|
1056
|
+
}),
|
|
1057
|
+
}
|
|
1058
|
+
: options?.previous;
|
|
1059
|
+
const {
|
|
1060
|
+
selectedOptions: displaySelectedOptions,
|
|
1061
|
+
customInput,
|
|
1062
|
+
navigation,
|
|
1063
|
+
cancelled,
|
|
1064
|
+
timedOut,
|
|
1065
|
+
} = await askSingleQuestion(ui, displayQuestion, optionLabels, q.multi ?? false, {
|
|
1066
|
+
recommended: q.recommended,
|
|
1067
|
+
timeout: timeout ?? undefined,
|
|
1068
|
+
signal,
|
|
1069
|
+
initialSelection,
|
|
1070
|
+
navigation: options?.navigation,
|
|
1071
|
+
scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
|
|
1072
|
+
otherOptionLabel: shouldNumberOptions
|
|
1073
|
+
? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
|
|
1074
|
+
: undefined,
|
|
1075
|
+
});
|
|
1076
|
+
const selectedOptions = shouldNumberOptions
|
|
1077
|
+
? displaySelectedOptions.map(selected => {
|
|
1078
|
+
const displayIndex = optionLabels.indexOf(selected);
|
|
1079
|
+
return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
|
|
1080
|
+
})
|
|
1081
|
+
: displaySelectedOptions;
|
|
1082
|
+
if (activeRemoteReceipt) {
|
|
1083
|
+
// A remote answer won this question: settle its receipt with the outcome so
|
|
1084
|
+
// the answer source can acknowledge (commit) or release it.
|
|
1085
|
+
const settlement: AskSettlement =
|
|
1086
|
+
customInput !== undefined && customInput.trim().length === 0
|
|
1087
|
+
? { kind: "invalid", reason: "empty_custom" }
|
|
1088
|
+
: cancelled
|
|
1089
|
+
? { kind: "resolve_without_commit", reason: "cancelled" }
|
|
1090
|
+
: timedOut
|
|
1091
|
+
? { kind: "resolve_without_commit", reason: "timed_out" }
|
|
1092
|
+
: navigation === "back"
|
|
1093
|
+
? { kind: "resolve_without_commit", reason: "back_navigation" }
|
|
1094
|
+
: navigation === "forward" &&
|
|
1095
|
+
selectedOptions.length === 0 &&
|
|
1096
|
+
(customInput === undefined || customInput.trim().length === 0)
|
|
1097
|
+
? { kind: "resolve_without_commit", reason: "empty_navigation" }
|
|
1098
|
+
: selectedOptions.length > 0 || (customInput?.trim().length ?? 0) > 0
|
|
1099
|
+
? { kind: "commit" }
|
|
1100
|
+
: { kind: "resolve_without_commit", reason: "cancelled" };
|
|
1101
|
+
await settleActiveRemote(settlement);
|
|
1102
|
+
// An empty remote custom answer is rejected, not recorded: ask again.
|
|
1103
|
+
if (settlement.kind === "invalid") continue;
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
optionLabels: rawOptionLabels,
|
|
1107
|
+
selectedOptions,
|
|
1108
|
+
customInput,
|
|
1109
|
+
clarificationQuestion: undefined as string | undefined,
|
|
1110
|
+
navigation,
|
|
1111
|
+
cancelled,
|
|
1112
|
+
timedOut,
|
|
1113
|
+
};
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
await settleActiveRemote({
|
|
1116
|
+
kind: "resolve_without_commit",
|
|
1117
|
+
reason: error instanceof Error && error.name === "AbortError" ? "aborted" : "exception",
|
|
1118
|
+
});
|
|
1119
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1120
|
+
throw new ToolAbortError("Ask input was cancelled");
|
|
1121
|
+
}
|
|
1122
|
+
throw error;
|
|
863
1123
|
}
|
|
864
|
-
throw error;
|
|
865
|
-
}
|
|
866
1124
|
};
|
|
867
1125
|
|
|
868
1126
|
if (params.questions.length === 1) {
|
package/src/tools/bisect.ts
CHANGED
|
@@ -89,7 +89,10 @@ export interface BisectToolDetails {
|
|
|
89
89
|
reason?: string;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
// git < 2.55 prints "<sha> is the first bad commit"; git >= 2.55 quotes the
|
|
93
|
+
// term: "<sha> is the first 'bad' commit". Accept both so convergence is
|
|
94
|
+
// detected regardless of the host git version.
|
|
95
|
+
const FIRST_BAD_RE = /^([0-9a-f]{7,40}) is the first '?bad'? commit$/m;
|
|
93
96
|
const ONLY_SKIPPED_RE = /only '?skip'?ped commits left to test/i;
|
|
94
97
|
|
|
95
98
|
/** Parse the culprit SHA from `git bisect good|bad` output, or null when not yet converged. */
|