@howaboua/pi-ask 0.0.12 → 0.0.14
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 +11 -0
- package/README.md +2 -0
- package/ask/coordinator.ts +78 -48
- package/ask/extension-messages.ts +85 -0
- package/ask/normalize.ts +38 -0
- package/ask/requests.ts +189 -0
- package/ask/tool.ts +27 -8
- package/biome.json +1 -1
- package/index.ts +15 -43
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @howaboua/pi-ask
|
|
2
2
|
|
|
3
|
+
## 0.0.14
|
|
4
|
+
|
|
5
|
+
- Ask now separates exact choice labels from descriptions in forwarded questions.
|
|
6
|
+
|
|
7
|
+
## 0.0.13
|
|
8
|
+
|
|
9
|
+
- Ask now accepts supervisor replies through Shepherdr's `reply` action. Update and reload Ask and Shepherdr together on controllers and workers.
|
|
10
|
+
|
|
11
|
+
- Supervisors receive questions, choices and a reply template. Valid replies complete the worker's existing wait or steer panel. Human handoffs still require the user's confirmation.
|
|
12
|
+
- Unknown or misnamed fields, such as `options` instead of `choices`, and malformed choices now return errors naming the invalid field and accepted shape instead of silently dropping input.
|
|
13
|
+
|
|
3
14
|
## 0.0.12
|
|
4
15
|
|
|
5
16
|
- ### Improvements
|
package/README.md
CHANGED
|
@@ -26,6 +26,8 @@ For work only you can complete—sign-in, authorization, hardware access, or ano
|
|
|
26
26
|
|
|
27
27
|
When Pi runs inside Herdr with its Pi integration installed, only a waiting ask marks the pane as blocked. A steering ask remains pending while the agent continues.
|
|
28
28
|
|
|
29
|
+
With Shepherdr, supervising agents receive the question, choices and a ready-to-fill reply. Replying completes the same panel. Ordinary agent messages do not answer it, and pending questions remain visible in later reports. Human handoffs still require your action and confirmation.
|
|
30
|
+
|
|
29
31
|
`Other/rephrase` is always available. Submit it blank when the prompt needs to be rephrased, split, or followed up instead of answered as written.
|
|
30
32
|
|
|
31
33
|
## Prompt commands
|
package/ask/coordinator.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AskPrompt, AskResponse, PendingAsk } from "./contracts.js";
|
|
|
3
3
|
import { normalizeResponses, summarizeResponses } from "./normalize.js";
|
|
4
4
|
import type { PendingAskUpdate } from "./pending.js";
|
|
5
5
|
import { askWithPiUi } from "./pi-ui.js";
|
|
6
|
+
import { AskRequests, type RequestChange } from "./requests.js";
|
|
6
7
|
import { askInTui } from "./tui.js";
|
|
7
8
|
|
|
8
9
|
type AskInComposer = (
|
|
@@ -14,10 +15,11 @@ export interface AskCoordinatorOptions {
|
|
|
14
15
|
askInComposer?: AskInComposer;
|
|
15
16
|
deliverSteer?: (message: string) => void;
|
|
16
17
|
onPendingChange?: (update: PendingAskUpdate) => void;
|
|
17
|
-
|
|
18
|
+
onRequestChange?: (change: RequestChange) => void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
interface PresentAskOptions {
|
|
22
|
+
id: string;
|
|
21
23
|
onActiveChange?: (active: boolean) => void;
|
|
22
24
|
handoff?: boolean;
|
|
23
25
|
steering?: boolean;
|
|
@@ -35,41 +37,46 @@ export function createAskCoordinator({
|
|
|
35
37
|
askInComposer,
|
|
36
38
|
deliverSteer,
|
|
37
39
|
onPendingChange,
|
|
38
|
-
|
|
40
|
+
onRequestChange,
|
|
39
41
|
}: AskCoordinatorOptions = {}) {
|
|
42
|
+
const requests = new AskRequests(onRequestChange);
|
|
40
43
|
let generation = 0;
|
|
41
44
|
let sessionAbort = new AbortController();
|
|
42
45
|
let presentationTail = Promise.resolve();
|
|
43
46
|
const activeSteers = new Set<string>();
|
|
44
|
-
let presentedSteer: PendingAsk | undefined;
|
|
45
47
|
|
|
46
48
|
const present = (
|
|
47
49
|
ctx: ExtensionContext,
|
|
48
50
|
prompts: AskPrompt[],
|
|
49
51
|
{
|
|
52
|
+
id,
|
|
50
53
|
handoff = false,
|
|
51
54
|
steering = false,
|
|
52
55
|
signal,
|
|
53
56
|
onActiveChange,
|
|
54
|
-
}: PresentAskOptions
|
|
57
|
+
}: PresentAskOptions,
|
|
55
58
|
): Promise<unknown> => {
|
|
59
|
+
const request = requests.open({ id, prompts, handoff, steering }, signal);
|
|
56
60
|
const run = async () => {
|
|
57
|
-
if (signal
|
|
61
|
+
if (request.signal.aborted) return;
|
|
58
62
|
try {
|
|
59
63
|
onActiveChange?.(true);
|
|
60
|
-
|
|
61
|
-
? await askInComposer(prompts, signal)
|
|
64
|
+
const response = askInComposer
|
|
65
|
+
? await askInComposer(prompts, request.signal)
|
|
62
66
|
: ctx.mode === "tui"
|
|
63
67
|
? await askInTui(ctx, prompts, {
|
|
64
68
|
handoff,
|
|
65
69
|
steering,
|
|
66
|
-
|
|
70
|
+
signal: request.signal,
|
|
67
71
|
})
|
|
68
72
|
: await askWithPiUi(ctx, prompts, {
|
|
69
73
|
handoff,
|
|
70
74
|
steering,
|
|
71
|
-
|
|
75
|
+
signal: request.signal,
|
|
72
76
|
});
|
|
77
|
+
request.select(response);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
request.fail(error);
|
|
73
80
|
} finally {
|
|
74
81
|
onActiveChange?.(false);
|
|
75
82
|
}
|
|
@@ -79,7 +86,7 @@ export function createAskCoordinator({
|
|
|
79
86
|
() => undefined,
|
|
80
87
|
() => undefined,
|
|
81
88
|
);
|
|
82
|
-
return
|
|
89
|
+
return request.response;
|
|
83
90
|
};
|
|
84
91
|
|
|
85
92
|
const notifyFailure = (ctx: ExtensionContext, error: unknown) => {
|
|
@@ -102,7 +109,7 @@ export function createAskCoordinator({
|
|
|
102
109
|
ctx: ExtensionContext,
|
|
103
110
|
id: string,
|
|
104
111
|
responses?: AskResponse[],
|
|
105
|
-
) => {
|
|
112
|
+
): boolean => {
|
|
106
113
|
activeSteers.delete(id);
|
|
107
114
|
try {
|
|
108
115
|
onPendingChange?.({
|
|
@@ -111,8 +118,10 @@ export function createAskCoordinator({
|
|
|
111
118
|
id,
|
|
112
119
|
...(responses ? { responses } : { dismissed: true }),
|
|
113
120
|
});
|
|
121
|
+
return true;
|
|
114
122
|
} catch (error) {
|
|
115
123
|
notifyFailure(ctx, error);
|
|
124
|
+
return false;
|
|
116
125
|
}
|
|
117
126
|
};
|
|
118
127
|
|
|
@@ -133,49 +142,74 @@ export function createAskCoordinator({
|
|
|
133
142
|
activeSteers.add(request.id);
|
|
134
143
|
const requestGeneration = generation;
|
|
135
144
|
void present(ctx, request.prompts, {
|
|
145
|
+
id: request.id,
|
|
136
146
|
steering: true,
|
|
137
147
|
signal: sessionAbort.signal,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
})
|
|
149
|
+
.then(
|
|
150
|
+
(responses) => {
|
|
151
|
+
if (requestGeneration !== generation || sessionAbort.signal.aborted)
|
|
152
|
+
return;
|
|
153
|
+
if (deliver(ctx, steerResponse(request.prompts, responses))) {
|
|
154
|
+
const saved = close(
|
|
155
|
+
ctx,
|
|
156
|
+
request.id,
|
|
157
|
+
normalizeResponses(request.prompts, responses) ?? undefined,
|
|
158
|
+
);
|
|
159
|
+
requests.complete(
|
|
160
|
+
request.id,
|
|
161
|
+
saved
|
|
162
|
+
? undefined
|
|
163
|
+
: "Answer delivered, but question state could not be saved; inspect the agent before retrying",
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
requests.complete(
|
|
167
|
+
request.id,
|
|
168
|
+
"Answer could not be delivered; the question is open again",
|
|
169
|
+
);
|
|
170
|
+
activeSteers.delete(request.id);
|
|
171
|
+
startSteer(request, ctx, false);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
(error: unknown) => {
|
|
175
|
+
if (requestGeneration !== generation || sessionAbort.signal.aborted)
|
|
176
|
+
return;
|
|
177
|
+
const message = notifyFailure(ctx, error);
|
|
178
|
+
if (
|
|
179
|
+
deliver(
|
|
180
|
+
ctx,
|
|
181
|
+
`The asynchronous ask failed before I could answer: ${message}`,
|
|
182
|
+
)
|
|
183
|
+
) {
|
|
184
|
+
const saved = close(ctx, request.id);
|
|
185
|
+
requests.complete(
|
|
186
|
+
request.id,
|
|
187
|
+
saved
|
|
188
|
+
? undefined
|
|
189
|
+
: "Question state could not be saved; inspect the agent before retrying",
|
|
190
|
+
);
|
|
191
|
+
} else requests.complete(request.id, message);
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
.catch((error: unknown) => {
|
|
146
195
|
if (requestGeneration !== generation || sessionAbort.signal.aborted)
|
|
147
196
|
return;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
request.id,
|
|
152
|
-
normalizeResponses(request.prompts, responses) ?? undefined,
|
|
153
|
-
);
|
|
154
|
-
},
|
|
155
|
-
(error: unknown) => {
|
|
156
|
-
if (requestGeneration !== generation || sessionAbort.signal.aborted)
|
|
157
|
-
return;
|
|
158
|
-
const message = notifyFailure(ctx, error);
|
|
159
|
-
if (
|
|
160
|
-
deliver(
|
|
161
|
-
ctx,
|
|
162
|
-
`The asynchronous ask failed before I could answer: ${message}`,
|
|
163
|
-
)
|
|
164
|
-
)
|
|
165
|
-
close(ctx, request.id);
|
|
166
|
-
},
|
|
167
|
-
);
|
|
197
|
+
requests.complete(request.id, String(error));
|
|
198
|
+
notifyFailure(ctx, error);
|
|
199
|
+
});
|
|
168
200
|
};
|
|
169
201
|
|
|
170
202
|
return {
|
|
171
203
|
canSteer: deliverSteer !== undefined,
|
|
172
204
|
present,
|
|
205
|
+
reply: (id: string, message: string) => requests.reply(id, message),
|
|
206
|
+
complete: (id: string, error?: string) => requests.complete(id, error),
|
|
173
207
|
requestSteer(request: PendingAsk, ctx: ExtensionContext) {
|
|
174
208
|
startSteer(request, ctx, true);
|
|
175
209
|
},
|
|
176
210
|
restorePending(pending: readonly PendingAsk[], ctx: ExtensionContext) {
|
|
177
211
|
generation++;
|
|
178
|
-
|
|
212
|
+
requests.reset();
|
|
179
213
|
sessionAbort.abort();
|
|
180
214
|
sessionAbort = new AbortController();
|
|
181
215
|
presentationTail = Promise.resolve();
|
|
@@ -184,14 +218,10 @@ export function createAskCoordinator({
|
|
|
184
218
|
for (const request of pending) startSteer(request, ctx, false);
|
|
185
219
|
},
|
|
186
220
|
shutdown() {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
generation++;
|
|
192
|
-
sessionAbort.abort();
|
|
193
|
-
activeSteers.clear();
|
|
194
|
-
}
|
|
221
|
+
generation++;
|
|
222
|
+
requests.reset();
|
|
223
|
+
sessionAbort.abort();
|
|
224
|
+
activeSteers.clear();
|
|
195
225
|
},
|
|
196
226
|
get sessionSignal(): AbortSignal {
|
|
197
227
|
return sessionAbort.signal;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AskRequest, RequestChange } from "./requests.js";
|
|
3
|
+
|
|
4
|
+
const ENTRY = "shepherdr-extension-message";
|
|
5
|
+
const REPLY_CHANNEL = "@howaboua/pi-shepherdr/extension-reply/v1";
|
|
6
|
+
const EXTENSION = "@howaboua/pi-ask";
|
|
7
|
+
|
|
8
|
+
function questionText(request: AskRequest): string {
|
|
9
|
+
return [
|
|
10
|
+
request.handoff
|
|
11
|
+
? "Human action required; ask the user to complete this handoff"
|
|
12
|
+
: request.steering
|
|
13
|
+
? "Question pending; the agent can continue reversible work, but still needs an answer"
|
|
14
|
+
: "Question pending; the agent is waiting for an answer",
|
|
15
|
+
...request.prompts.map((prompt, index) =>
|
|
16
|
+
[
|
|
17
|
+
`${index + 1}. ${prompt.title}`,
|
|
18
|
+
...(prompt.body ? [prompt.body] : []),
|
|
19
|
+
...(prompt.choices.length
|
|
20
|
+
? [
|
|
21
|
+
`Choose ${prompt.multiple ? "one or more" : "one"}:`,
|
|
22
|
+
...prompt.choices.map(
|
|
23
|
+
(choice) =>
|
|
24
|
+
`- ${JSON.stringify(choice.label)}${choice.description ? `\n ${choice.description}` : ""}`,
|
|
25
|
+
),
|
|
26
|
+
]
|
|
27
|
+
: ["Free-text answer"]),
|
|
28
|
+
].join("\n"),
|
|
29
|
+
),
|
|
30
|
+
...(request.handoff
|
|
31
|
+
? []
|
|
32
|
+
: [
|
|
33
|
+
"Reply with a JSON array in prompt order: selections contains exact choice labels; other supplies free text; comment is optional. Blank other requests rephrasing. Replace the placeholders in the reply below, do not send them literally. An ordinary agent message does not resolve this question",
|
|
34
|
+
]),
|
|
35
|
+
].join("\n\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Ask owns question text and reply semantics; Shepherdr carries opaque strings. */
|
|
39
|
+
export function publishAskRequest(
|
|
40
|
+
pi: ExtensionAPI,
|
|
41
|
+
change: RequestChange,
|
|
42
|
+
): void {
|
|
43
|
+
const base = { version: 1, extension: EXTENSION };
|
|
44
|
+
if (change.state === "closed") {
|
|
45
|
+
pi.appendEntry(ENTRY, { ...base, id: change.id, state: "closed" });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const { request } = change;
|
|
49
|
+
pi.appendEntry(ENTRY, {
|
|
50
|
+
...base,
|
|
51
|
+
id: request.id,
|
|
52
|
+
state: "pending",
|
|
53
|
+
message: questionText(request),
|
|
54
|
+
blocking: !request.steering,
|
|
55
|
+
...(request.handoff
|
|
56
|
+
? {}
|
|
57
|
+
: {
|
|
58
|
+
reply: JSON.stringify(
|
|
59
|
+
request.prompts.map((prompt) =>
|
|
60
|
+
prompt.choices.length
|
|
61
|
+
? { selections: ["<choice label>"] }
|
|
62
|
+
: { other: "<answer>" },
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function registerAskReplies(
|
|
70
|
+
pi: ExtensionAPI,
|
|
71
|
+
reply: (id: string, message: string) => Promise<string>,
|
|
72
|
+
): void {
|
|
73
|
+
pi.events.on(REPLY_CHANNEL, (value: unknown) => {
|
|
74
|
+
if (!value || typeof value !== "object") return;
|
|
75
|
+
const event = value as Record<string, unknown>;
|
|
76
|
+
if (
|
|
77
|
+
event["extension"] !== EXTENSION ||
|
|
78
|
+
typeof event["id"] !== "string" ||
|
|
79
|
+
typeof event["message"] !== "string" ||
|
|
80
|
+
typeof event["respond"] !== "function"
|
|
81
|
+
)
|
|
82
|
+
return;
|
|
83
|
+
event["respond"](reply(event["id"], event["message"]));
|
|
84
|
+
});
|
|
85
|
+
}
|
package/ask/normalize.ts
CHANGED
|
@@ -44,6 +44,44 @@ function normalizePrompt(
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const ASK_FIELDS = ["help", "handoff", "prompts", "delivery"];
|
|
48
|
+
const PROMPT_FIELDS = ["title", "body", "multiple", "choices"];
|
|
49
|
+
const CHOICE_FIELDS = ["label", "description"];
|
|
50
|
+
const CHOICE_SHAPE = "{ label, description? }";
|
|
51
|
+
|
|
52
|
+
function unknownField(
|
|
53
|
+
value: Record<string, unknown>,
|
|
54
|
+
fields: string[],
|
|
55
|
+
path: string,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
const field = Object.keys(value).find((key) => !fields.includes(key));
|
|
58
|
+
return field === undefined
|
|
59
|
+
? undefined
|
|
60
|
+
: `Unknown ask field ${path}${field}; allowed: ${fields.join(", ")}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Why a tool call would lose input to normalization, so a misnamed field fails instead of vanishing. */
|
|
64
|
+
export function askInputProblem(params: unknown): string | undefined {
|
|
65
|
+
if (!isRecord(params)) return undefined;
|
|
66
|
+
const top = unknownField(params, ASK_FIELDS, "");
|
|
67
|
+
if (top) return top;
|
|
68
|
+
if (!Array.isArray(params["prompts"])) return undefined;
|
|
69
|
+
for (const [index, prompt] of params["prompts"].entries()) {
|
|
70
|
+
if (!isRecord(prompt)) continue;
|
|
71
|
+
const path = `prompts[${index}]`;
|
|
72
|
+
const field = unknownField(prompt, PROMPT_FIELDS, `${path}.`);
|
|
73
|
+
if (field) return `${field}; choices: [${CHOICE_SHAPE}]`;
|
|
74
|
+
if (!Array.isArray(prompt["choices"])) continue;
|
|
75
|
+
for (const [choiceIndex, choice] of prompt["choices"].entries()) {
|
|
76
|
+
const choicePath = `${path}.choices[${choiceIndex}]`;
|
|
77
|
+
if (!isRecord(choice)) return `${choicePath} must be ${CHOICE_SHAPE}`;
|
|
78
|
+
const choiceField = unknownField(choice, CHOICE_FIELDS, `${choicePath}.`);
|
|
79
|
+
if (choiceField) return choiceField;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
47
85
|
export function isSteeringAskInput(input: unknown): boolean {
|
|
48
86
|
return (
|
|
49
87
|
isRecord(input) &&
|
package/ask/requests.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { AskPrompt, AskResponse } from "./contracts.js";
|
|
2
|
+
import { customSelectionFor } from "./state.js";
|
|
3
|
+
|
|
4
|
+
export interface AskRequest {
|
|
5
|
+
id: string;
|
|
6
|
+
prompts: AskPrompt[];
|
|
7
|
+
handoff: boolean;
|
|
8
|
+
steering: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RequestChange =
|
|
12
|
+
| { state: "pending"; request: AskRequest }
|
|
13
|
+
| { state: "closed"; id: string };
|
|
14
|
+
|
|
15
|
+
type Completion = { error: string } | { message: string };
|
|
16
|
+
|
|
17
|
+
interface OpenRequest {
|
|
18
|
+
request: AskRequest;
|
|
19
|
+
controller: AbortController;
|
|
20
|
+
selected: boolean;
|
|
21
|
+
resolve: (response: unknown) => void;
|
|
22
|
+
finish: (result: Completion) => void;
|
|
23
|
+
completion: Promise<Completion>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseAnswers(prompts: AskPrompt[], text: string): AskResponse[] {
|
|
27
|
+
let input: unknown;
|
|
28
|
+
try {
|
|
29
|
+
input = JSON.parse(text);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error("Reply must be a JSON array of answers in prompt order");
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(input) || input.length !== prompts.length)
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Provide exactly ${prompts.length} answers in prompt order`,
|
|
36
|
+
);
|
|
37
|
+
return prompts.map((prompt, index) => {
|
|
38
|
+
const value: unknown = input[index];
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
40
|
+
throw new Error(`Answer ${index + 1} must be an object`);
|
|
41
|
+
const answer = value as Record<string, unknown>;
|
|
42
|
+
if (
|
|
43
|
+
Object.keys(answer).some(
|
|
44
|
+
(key) => !["selections", "other", "comment"].includes(key),
|
|
45
|
+
) ||
|
|
46
|
+
(answer["other"] !== undefined && typeof answer["other"] !== "string") ||
|
|
47
|
+
(answer["comment"] !== undefined && typeof answer["comment"] !== "string")
|
|
48
|
+
)
|
|
49
|
+
throw new Error(`Answer ${index + 1}: use selections, other or comment`);
|
|
50
|
+
const selections = answer["selections"] ?? [];
|
|
51
|
+
if (
|
|
52
|
+
!Array.isArray(selections) ||
|
|
53
|
+
!selections.every(
|
|
54
|
+
(value): value is string =>
|
|
55
|
+
typeof value === "string" &&
|
|
56
|
+
prompt.choices.some((choice) => choice.label === value),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Answer ${index + 1}: selections must match choice labels`,
|
|
61
|
+
);
|
|
62
|
+
const selected = [...new Set(selections)];
|
|
63
|
+
if (typeof answer["other"] === "string")
|
|
64
|
+
selected.push(customSelectionFor(answer["other"]));
|
|
65
|
+
const comment =
|
|
66
|
+
typeof answer["comment"] === "string" ? answer["comment"].trim() : "";
|
|
67
|
+
if (!selected.length && !comment)
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Answer ${index + 1}: supply a selection, other or comment`,
|
|
70
|
+
);
|
|
71
|
+
if (!prompt.multiple && selected.length > 1)
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Answer ${index + 1} accepts only one selection or other`,
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
id: prompt.id,
|
|
77
|
+
selections: selected,
|
|
78
|
+
...(comment ? { comment } : {}),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Owns the race between the human panel, remote replies and session cancellation. */
|
|
84
|
+
export class AskRequests {
|
|
85
|
+
private readonly requests = new Map<string, OpenRequest>();
|
|
86
|
+
private readonly onChange: ((change: RequestChange) => void) | undefined;
|
|
87
|
+
constructor(onChange?: (change: RequestChange) => void) {
|
|
88
|
+
this.onChange = onChange;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
open(request: AskRequest, signal?: AbortSignal) {
|
|
92
|
+
if (this.requests.has(request.id))
|
|
93
|
+
throw new Error("Ask is already pending");
|
|
94
|
+
let resolve!: (response: unknown) => void;
|
|
95
|
+
let reject!: (error: unknown) => void;
|
|
96
|
+
let finish!: (result: Completion) => void;
|
|
97
|
+
const response = new Promise<unknown>((done, fail) => {
|
|
98
|
+
resolve = done;
|
|
99
|
+
reject = fail;
|
|
100
|
+
});
|
|
101
|
+
const completion = new Promise<Completion>((done) => {
|
|
102
|
+
finish = done;
|
|
103
|
+
});
|
|
104
|
+
const entry: OpenRequest = {
|
|
105
|
+
request,
|
|
106
|
+
controller: new AbortController(),
|
|
107
|
+
selected: false,
|
|
108
|
+
resolve,
|
|
109
|
+
finish,
|
|
110
|
+
completion,
|
|
111
|
+
};
|
|
112
|
+
const select = (value: unknown) => {
|
|
113
|
+
if (entry.selected) return;
|
|
114
|
+
entry.selected = true;
|
|
115
|
+
resolve(value);
|
|
116
|
+
entry.controller.abort();
|
|
117
|
+
};
|
|
118
|
+
const abort = () => select(null);
|
|
119
|
+
this.onChange?.({ state: "pending", request });
|
|
120
|
+
this.requests.set(request.id, entry);
|
|
121
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
122
|
+
if (signal?.aborted) abort();
|
|
123
|
+
return {
|
|
124
|
+
response: response.finally(() =>
|
|
125
|
+
signal?.removeEventListener("abort", abort),
|
|
126
|
+
),
|
|
127
|
+
signal: entry.controller.signal,
|
|
128
|
+
select,
|
|
129
|
+
fail(error: unknown) {
|
|
130
|
+
if (entry.selected) return;
|
|
131
|
+
entry.selected = true;
|
|
132
|
+
reject(error);
|
|
133
|
+
entry.controller.abort();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async reply(id: string, text: string): Promise<string> {
|
|
139
|
+
const entry = this.requests.get(id);
|
|
140
|
+
if (!entry)
|
|
141
|
+
throw new Error(
|
|
142
|
+
"This question is no longer pending; read the agent's latest state",
|
|
143
|
+
);
|
|
144
|
+
if (entry.request.handoff)
|
|
145
|
+
throw new Error(
|
|
146
|
+
"This handoff requires the user's action and confirmation",
|
|
147
|
+
);
|
|
148
|
+
if (entry.selected)
|
|
149
|
+
throw new Error(
|
|
150
|
+
"This question already has a response; read the agent's latest state",
|
|
151
|
+
);
|
|
152
|
+
const answers = parseAnswers(entry.request.prompts, text);
|
|
153
|
+
entry.selected = true;
|
|
154
|
+
entry.resolve(answers);
|
|
155
|
+
entry.controller.abort();
|
|
156
|
+
const result = await entry.completion;
|
|
157
|
+
if ("error" in result) throw new Error(result.error);
|
|
158
|
+
return result.message;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
complete(id: string, error?: string): void {
|
|
162
|
+
const entry = this.requests.get(id);
|
|
163
|
+
if (!entry) return;
|
|
164
|
+
this.requests.delete(id);
|
|
165
|
+
try {
|
|
166
|
+
if (!error || !entry.request.steering)
|
|
167
|
+
this.onChange?.({ state: "closed", id });
|
|
168
|
+
entry.finish(error ? { error } : { message: "Answer accepted" });
|
|
169
|
+
} catch (failure) {
|
|
170
|
+
entry.finish({
|
|
171
|
+
error: `Response received, but question state could not be saved: ${String(failure)}`,
|
|
172
|
+
});
|
|
173
|
+
throw failure;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
reset(): void {
|
|
178
|
+
for (const entry of this.requests.values()) {
|
|
179
|
+
entry.selected = true;
|
|
180
|
+
entry.resolve(null);
|
|
181
|
+
entry.controller.abort();
|
|
182
|
+
this.complete(
|
|
183
|
+
entry.request.id,
|
|
184
|
+
"Session changed before the answer was delivered; read the agent's latest state",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
this.requests.clear();
|
|
188
|
+
}
|
|
189
|
+
}
|
package/ask/tool.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Check } from "typebox/value";
|
|
3
4
|
import { AskParameters, type AskPrompt } from "./contracts.js";
|
|
4
5
|
import {
|
|
5
6
|
type AskCoordinatorOptions,
|
|
6
7
|
createAskCoordinator,
|
|
7
8
|
} from "./coordinator.js";
|
|
8
9
|
import {
|
|
10
|
+
askInputProblem,
|
|
9
11
|
isAskHelpInput,
|
|
10
12
|
isSteeringAskInput,
|
|
11
13
|
normalizeAskInput,
|
|
@@ -34,13 +36,13 @@ export function createAskRuntime({
|
|
|
34
36
|
deliverSteer,
|
|
35
37
|
onBlockedChange,
|
|
36
38
|
onPendingChange,
|
|
37
|
-
|
|
39
|
+
onRequestChange,
|
|
38
40
|
}: AskToolOptions = {}) {
|
|
39
41
|
const coordinator = createAskCoordinator({
|
|
40
42
|
...(askInComposer ? { askInComposer } : {}),
|
|
41
43
|
...(deliverSteer ? { deliverSteer } : {}),
|
|
42
44
|
...(onPendingChange ? { onPendingChange } : {}),
|
|
43
|
-
...(
|
|
45
|
+
...(onRequestChange ? { onRequestChange } : {}),
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
const tool = defineTool({
|
|
@@ -49,6 +51,14 @@ export function createAskRuntime({
|
|
|
49
51
|
description: "Request user input or action; {} for help",
|
|
50
52
|
parameters: AskParameters,
|
|
51
53
|
executionMode: "sequential",
|
|
54
|
+
prepareArguments(input) {
|
|
55
|
+
const params = input === undefined ? {} : input;
|
|
56
|
+
const problem = askInputProblem(params);
|
|
57
|
+
if (problem) throw new Error(problem);
|
|
58
|
+
if (!Check(AskParameters, params))
|
|
59
|
+
throw new Error("Invalid ask arguments; ask({}) returns help");
|
|
60
|
+
return params;
|
|
61
|
+
},
|
|
52
62
|
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
53
63
|
if (isAskHelpInput(params)) {
|
|
54
64
|
return {
|
|
@@ -104,12 +114,20 @@ export function createAskRuntime({
|
|
|
104
114
|
const presentationSignal = signal
|
|
105
115
|
? AbortSignal.any([signal, coordinator.sessionSignal])
|
|
106
116
|
: coordinator.sessionSignal;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
let rawResponses: unknown;
|
|
118
|
+
try {
|
|
119
|
+
rawResponses = await coordinator.present(ctx, prompts, {
|
|
120
|
+
id: toolCallId,
|
|
121
|
+
handoff,
|
|
122
|
+
signal: presentationSignal,
|
|
123
|
+
onActiveChange: (active) =>
|
|
124
|
+
onBlockedChange?.({ ...blockedState, active }),
|
|
125
|
+
});
|
|
126
|
+
coordinator.complete(toolCallId);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
coordinator.complete(toolCallId, String(error));
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
113
131
|
const responses = normalizeResponses(prompts, rawResponses);
|
|
114
132
|
if (!responses) {
|
|
115
133
|
return {
|
|
@@ -156,6 +174,7 @@ export function createAskRuntime({
|
|
|
156
174
|
|
|
157
175
|
return {
|
|
158
176
|
tool,
|
|
177
|
+
reply: coordinator.reply,
|
|
159
178
|
restorePending: coordinator.restorePending,
|
|
160
179
|
shutdown: coordinator.shutdown,
|
|
161
180
|
};
|
package/biome.json
CHANGED
package/index.ts
CHANGED
|
@@ -9,6 +9,10 @@ import {
|
|
|
9
9
|
createSteerDelivery,
|
|
10
10
|
type TryCodexDeveloperMessage,
|
|
11
11
|
} from "./ask/delivery.js";
|
|
12
|
+
import {
|
|
13
|
+
publishAskRequest,
|
|
14
|
+
registerAskReplies,
|
|
15
|
+
} from "./ask/extension-messages.js";
|
|
12
16
|
import { isAskHelpInput, isSteeringAskInput } from "./ask/normalize.js";
|
|
13
17
|
import { PENDING_ASK_ENTRY_TYPE, readPendingAsks } from "./ask/pending.js";
|
|
14
18
|
import { createAskRuntime, createAskTool } from "./ask/tool.js";
|
|
@@ -27,57 +31,25 @@ export default async function humanInTheLoop(pi: ExtensionAPI): Promise<void> {
|
|
|
27
31
|
registerPackageChangelog(pi);
|
|
28
32
|
const trySendCodexDeveloperMessage = await loadCodexDeveloperMessageSender();
|
|
29
33
|
const askRuntime = createAskRuntime({
|
|
34
|
+
onRequestChange: (change) => publishAskRequest(pi, change),
|
|
30
35
|
deliverSteer: createSteerDelivery(pi, trySendCodexDeveloperMessage),
|
|
31
36
|
onBlockedChange: (state) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
};
|
|
43
|
-
// Publish before Herdr reports blocked, including calls nested inside exec.
|
|
44
|
-
try {
|
|
45
|
-
pi.appendEntry(
|
|
46
|
-
"pi-ask-active",
|
|
47
|
-
state.active
|
|
48
|
-
? {
|
|
49
|
-
version: 1,
|
|
50
|
-
state: "active",
|
|
51
|
-
id: state.id,
|
|
52
|
-
handoff: state.handoff,
|
|
53
|
-
prompts: state.prompts,
|
|
54
|
-
}
|
|
55
|
-
: { version: 1, state: "closed", id: state.id },
|
|
56
|
-
);
|
|
57
|
-
} finally {
|
|
58
|
-
if (!state.active) notify();
|
|
59
|
-
}
|
|
60
|
-
if (state.active) notify();
|
|
37
|
+
pi.events.emit(REALTIME_VOICE_PROMPT_CHANNEL, {
|
|
38
|
+
id: state.id,
|
|
39
|
+
active: state.active,
|
|
40
|
+
prompt: state.prompt,
|
|
41
|
+
});
|
|
42
|
+
pi.events.emit("herdr:blocked", {
|
|
43
|
+
active: state.active,
|
|
44
|
+
label: state.label,
|
|
45
|
+
});
|
|
61
46
|
},
|
|
62
47
|
onPendingChange: (update) => {
|
|
63
48
|
pi.appendEntry(PENDING_ASK_ENTRY_TYPE, update);
|
|
64
49
|
},
|
|
65
|
-
onSteerActiveChange: (request, active) => {
|
|
66
|
-
pi.appendEntry(
|
|
67
|
-
"pi-ask-active",
|
|
68
|
-
active
|
|
69
|
-
? {
|
|
70
|
-
version: 1,
|
|
71
|
-
state: "active",
|
|
72
|
-
id: request.id,
|
|
73
|
-
delivery: "steer",
|
|
74
|
-
prompts: request.prompts,
|
|
75
|
-
}
|
|
76
|
-
: { version: 1, state: "closed", id: request.id },
|
|
77
|
-
);
|
|
78
|
-
},
|
|
79
50
|
});
|
|
80
51
|
const ask = askRuntime.tool;
|
|
52
|
+
registerAskReplies(pi, askRuntime.reply);
|
|
81
53
|
const restorePending = (ctx: ExtensionContext) => {
|
|
82
54
|
askRuntime.restorePending(
|
|
83
55
|
readPendingAsks(ctx.sessionManager.getBranch()),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@howaboua/pi-ask",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Interactive decisions, review triage, and human handoffs for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
]
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@howaboua/pi-codex-conversion": ">=3.0.
|
|
48
|
+
"@howaboua/pi-codex-conversion": ">=3.0.51",
|
|
49
49
|
"@earendil-works/pi-ai": ">=1.0.0",
|
|
50
50
|
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
51
51
|
"@earendil-works/pi-tui": ">=1.0.0",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@howaboua/pi-codex-conversion": "3.0.
|
|
61
|
-
"@biomejs/biome": "^2.5.
|
|
60
|
+
"@howaboua/pi-codex-conversion": "3.0.51",
|
|
61
|
+
"@biomejs/biome": "^2.5.15",
|
|
62
62
|
"@earendil-works/pi-ai": "1.1.0",
|
|
63
63
|
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
64
64
|
"@earendil-works/pi-tui": "1.1.0",
|
|
65
|
-
"typebox": "1.3.
|
|
65
|
+
"typebox": "1.3.36",
|
|
66
66
|
"typescript": "^7.0.2"
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|