@howaboua/pi-ask 0.0.11 → 0.0.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 +12 -0
- package/README.md +2 -0
- package/ask/contracts.ts +2 -1
- package/ask/coordinator.ts +78 -48
- package/ask/extension-messages.ts +85 -0
- package/ask/normalize.ts +45 -0
- package/ask/requests.ts +189 -0
- package/ask/tool.ts +47 -14
- package/index.ts +19 -47
- package/package.json +7 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @howaboua/pi-ask
|
|
2
2
|
|
|
3
|
+
## 0.0.13
|
|
4
|
+
|
|
5
|
+
- Ask now accepts supervisor replies through Shepherdr's `reply` action. Update and reload Ask and Shepherdr together on controllers and workers.
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
## 0.0.12
|
|
11
|
+
|
|
12
|
+
- ### Improvements
|
|
13
|
+
- Ask returns help when called without arguments, including an empty structured object. Explicit help remains supported.
|
|
14
|
+
|
|
3
15
|
## 0.0.11
|
|
4
16
|
|
|
5
17
|
- Parents can receive and answer asynchronous questions through Shepherdr while workers continue.
|
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/contracts.ts
CHANGED
|
@@ -16,8 +16,9 @@ const PromptSchema = Type.Object({
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
export const AskParameters = Type.Object({
|
|
19
|
+
help: Type.Optional(Type.Literal(true)),
|
|
19
20
|
handoff: Type.Optional(Type.Boolean()),
|
|
20
|
-
prompts: Type.Array(PromptSchema),
|
|
21
|
+
prompts: Type.Optional(Type.Array(PromptSchema)),
|
|
21
22
|
delivery: Type.Optional(StringEnum(ASK_DELIVERIES)),
|
|
22
23
|
});
|
|
23
24
|
|
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
|
+
`- ${choice.label}${choice.description ? `: ${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) &&
|
|
@@ -52,6 +90,13 @@ export function isSteeringAskInput(input: unknown): boolean {
|
|
|
52
90
|
);
|
|
53
91
|
}
|
|
54
92
|
|
|
93
|
+
export function isAskHelpInput(input: unknown): boolean {
|
|
94
|
+
if (input === undefined) return true;
|
|
95
|
+
if (!isRecord(input) || Array.isArray(input)) return false;
|
|
96
|
+
const keys = Object.keys(input);
|
|
97
|
+
return keys.length === 0 || (keys.length === 1 && input["help"] === true);
|
|
98
|
+
}
|
|
99
|
+
|
|
55
100
|
export function normalizeAskInput(params: unknown): {
|
|
56
101
|
delivery: "wait" | "steer";
|
|
57
102
|
handoff: boolean;
|
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,14 @@
|
|
|
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,
|
|
11
|
+
isAskHelpInput,
|
|
9
12
|
isSteeringAskInput,
|
|
10
13
|
normalizeAskInput,
|
|
11
14
|
normalizeResponses,
|
|
@@ -33,27 +36,46 @@ export function createAskRuntime({
|
|
|
33
36
|
deliverSteer,
|
|
34
37
|
onBlockedChange,
|
|
35
38
|
onPendingChange,
|
|
36
|
-
|
|
39
|
+
onRequestChange,
|
|
37
40
|
}: AskToolOptions = {}) {
|
|
38
41
|
const coordinator = createAskCoordinator({
|
|
39
42
|
...(askInComposer ? { askInComposer } : {}),
|
|
40
43
|
...(deliverSteer ? { deliverSteer } : {}),
|
|
41
44
|
...(onPendingChange ? { onPendingChange } : {}),
|
|
42
|
-
...(
|
|
45
|
+
...(onRequestChange ? { onRequestChange } : {}),
|
|
43
46
|
});
|
|
44
47
|
|
|
45
48
|
const tool = defineTool({
|
|
46
49
|
name: "ask",
|
|
47
50
|
label: "Ask",
|
|
48
|
-
description: "Request user input or action",
|
|
51
|
+
description: "Request user input or action; {} for help",
|
|
49
52
|
parameters: AskParameters,
|
|
50
|
-
promptGuidelines: [
|
|
51
|
-
"ask: Omit delivery to wait; steer only while reversible work can continue. Handoffs wait for user action; state the completion signal",
|
|
52
|
-
"ask: For reviews, make each finding a prompt with disposition choices; do not report first.",
|
|
53
|
-
"ask: Other/rephrase is automatic; omit choices for free text",
|
|
54
|
-
],
|
|
55
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
|
+
},
|
|
56
62
|
async execute(toolCallId, params, signal, _onUpdate, ctx) {
|
|
63
|
+
if (isAskHelpInput(params)) {
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
textContent(
|
|
67
|
+
[
|
|
68
|
+
"ask({}) or ask({ help: true }) returns help",
|
|
69
|
+
'ask({ prompts: [{ title, body?, multiple?, choices?: [{ label, description? }] }], delivery?: "wait"|"steer", handoff? })',
|
|
70
|
+
"Omit delivery to wait; steer only while reversible work can continue. Handoffs wait for user action; state the completion signal",
|
|
71
|
+
"For reviews, make each finding a prompt with disposition choices; do not report first",
|
|
72
|
+
"Other/rephrase is automatic; omit choices for free text. Blank Other/rephrase means rephrase or follow up",
|
|
73
|
+
].join("\n"),
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
details: { kind: "help" },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
57
79
|
const { delivery, handoff, prompts } = normalizeAskInput(params);
|
|
58
80
|
if (prompts.length === 0) {
|
|
59
81
|
throw new Error(
|
|
@@ -92,12 +114,20 @@ export function createAskRuntime({
|
|
|
92
114
|
const presentationSignal = signal
|
|
93
115
|
? AbortSignal.any([signal, coordinator.sessionSignal])
|
|
94
116
|
: coordinator.sessionSignal;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
|
101
131
|
const responses = normalizeResponses(prompts, rawResponses);
|
|
102
132
|
if (!responses) {
|
|
103
133
|
return {
|
|
@@ -115,6 +145,8 @@ export function createAskRuntime({
|
|
|
115
145
|
};
|
|
116
146
|
},
|
|
117
147
|
renderCall(args, theme, context) {
|
|
148
|
+
if (isAskHelpInput(args))
|
|
149
|
+
return new Text(theme.fg("toolTitle", theme.bold("ask help")), 0, 0);
|
|
118
150
|
const count = Array.isArray(args.prompts) ? args.prompts.length : 0;
|
|
119
151
|
const label =
|
|
120
152
|
args.handoff === true
|
|
@@ -142,6 +174,7 @@ export function createAskRuntime({
|
|
|
142
174
|
|
|
143
175
|
return {
|
|
144
176
|
tool,
|
|
177
|
+
reply: coordinator.reply,
|
|
145
178
|
restorePending: coordinator.restorePending,
|
|
146
179
|
shutdown: coordinator.shutdown,
|
|
147
180
|
};
|
package/index.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
createSteerDelivery,
|
|
10
10
|
type TryCodexDeveloperMessage,
|
|
11
11
|
} from "./ask/delivery.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
publishAskRequest,
|
|
14
|
+
registerAskReplies,
|
|
15
|
+
} from "./ask/extension-messages.js";
|
|
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";
|
|
15
19
|
import registerPackageChangelog from "./changelog.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()),
|
|
@@ -127,9 +99,9 @@ async function registerAskInCodeMode(
|
|
|
127
99
|
await import("@howaboua/pi-codex-conversion/code-mode");
|
|
128
100
|
const registration = registerCodeModeExtensionTools(pi, () => [
|
|
129
101
|
adaptToolForCodeMode(ask, {
|
|
130
|
-
blocking: (input) =>
|
|
131
|
-
|
|
132
|
-
|
|
102
|
+
blocking: (input) =>
|
|
103
|
+
!isAskHelpInput(input) && !isSteeringAskInput(input),
|
|
104
|
+
usage: "await tools.ask() // help",
|
|
133
105
|
}),
|
|
134
106
|
]);
|
|
135
107
|
pi.on("session_shutdown", () => registration.unregister());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@howaboua/pi-ask",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
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.50",
|
|
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,18 +57,17 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@howaboua/pi-codex-conversion": "3.0.
|
|
60
|
+
"@howaboua/pi-codex-conversion": "3.0.50",
|
|
61
61
|
"@biomejs/biome": "^2.5.10",
|
|
62
|
-
"@earendil-works/pi-ai": "1.0
|
|
63
|
-
"@earendil-works/pi-coding-agent": "1.0
|
|
64
|
-
"@earendil-works/pi-tui": "1.0
|
|
62
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
64
|
+
"@earendil-works/pi-tui": "1.1.0",
|
|
65
65
|
"typebox": "1.3.18",
|
|
66
66
|
"typescript": "^7.0.2"
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|
|
69
|
-
"check": "bun run lint && bun run typecheck
|
|
69
|
+
"check": "bun run lint && bun run typecheck",
|
|
70
70
|
"prepack": "bun run check",
|
|
71
|
-
"test": "bun test",
|
|
72
71
|
"typecheck": "tsgo --noEmit",
|
|
73
72
|
"lint": "biome check .",
|
|
74
73
|
"format": "biome check --write .",
|