@f5-sales-demo/xcsh 21.34.0 → 21.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/browser/capabilities.generated.ts +12 -0
- package/src/browser/capabilities.json +19 -7
- package/src/browser/chat-handler.ts +46 -0
- package/src/browser/extension-bridge-tools.ts +4 -2
- package/src/cli/args.ts +2 -1
- package/src/config/settings-schema.ts +5 -13
- package/src/config/settings.ts +0 -8
- package/src/extensibility/extensions/bundled/herdr-reporter.ts +2 -2
- package/src/extensibility/extensions/types.ts +1 -8
- package/src/herdr/client.ts +6 -1
- package/src/herdr/interactions.ts +221 -0
- package/src/internal-urls/api-catalog-discovery.ts +103 -0
- package/src/internal-urls/api-catalog-resolve.ts +16 -14
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/internal-urls/terraform-index.generated.ts +1 -15
- package/src/modes/components/request-user-input.ts +133 -0
- package/src/modes/components/settings-defs.ts +0 -8
- package/src/modes/controllers/event-controller.ts +46 -7
- package/src/modes/controllers/extension-ui-controller.ts +31 -25
- package/src/modes/interactive-mode.ts +10 -979
- package/src/modes/rpc/rpc-mode.ts +17 -0
- package/src/modes/rpc/rpc-types.ts +15 -0
- package/src/modes/types.ts +2 -3
- package/src/plan-mode/state.ts +0 -1
- package/src/prompts/system/default-mode-active.md +3 -0
- package/src/prompts/system/plan-mode-active.md +92 -84
- package/src/prompts/system/system-prompt.md +1 -2
- package/src/prompts/tools/request-user-input-async.md +1 -0
- package/src/remote-control/history.ts +11 -0
- package/src/remote-control/interactions.ts +7 -46
- package/src/remote-control/managed-sessions.ts +1 -5
- package/src/remote-control/router.ts +1 -0
- package/src/remote-control/session.ts +49 -19
- package/src/sdk.ts +7 -4
- package/src/session/agent-session.ts +263 -134
- package/src/session/turn-phase.ts +5 -0
- package/src/session/user-interactions.ts +246 -60
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/tools/context.ts +0 -6
- package/src/tools/index.ts +14 -10
- package/src/tools/plan-mode-guard.ts +3 -16
- package/src/tools/renderers.ts +0 -2
- package/src/tools/request-user-input.ts +144 -0
- package/src/modes/components/question-flow.ts +0 -324
- package/src/plan-mode/approved-plan.ts +0 -55
- package/src/prompts/system/plan-mode-reference.md +0 -14
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +0 -10
- package/src/prompts/tools/ask.md +0 -28
- package/src/prompts/tools/exit-plan-mode.md +0 -41
- package/src/session/question-types.ts +0 -56
- package/src/tools/ask.ts +0 -446
- package/src/tools/exit-plan-mode.ts +0 -101
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "21.
|
|
4
|
+
"version": "21.35.1",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -63,13 +63,13 @@
|
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
66
|
-
"@f5-sales-demo/pi-agent-core": "21.
|
|
67
|
-
"@f5-sales-demo/pi-ai": "21.
|
|
68
|
-
"@f5-sales-demo/pi-natives": "21.
|
|
69
|
-
"@f5-sales-demo/pi-resource-management": "21.
|
|
70
|
-
"@f5-sales-demo/pi-tui": "21.
|
|
71
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
72
|
-
"@f5-sales-demo/xcsh-stats": "21.
|
|
66
|
+
"@f5-sales-demo/pi-agent-core": "21.35.1",
|
|
67
|
+
"@f5-sales-demo/pi-ai": "21.35.1",
|
|
68
|
+
"@f5-sales-demo/pi-natives": "21.35.1",
|
|
69
|
+
"@f5-sales-demo/pi-resource-management": "21.35.1",
|
|
70
|
+
"@f5-sales-demo/pi-tui": "21.35.1",
|
|
71
|
+
"@f5-sales-demo/pi-utils": "21.35.1",
|
|
72
|
+
"@f5-sales-demo/xcsh-stats": "21.35.1",
|
|
73
73
|
"@mozilla/readability": "^0.6",
|
|
74
74
|
"@sinclair/typebox": "0.34.52",
|
|
75
75
|
"@xterm/headless": "^6.0",
|
|
@@ -837,6 +837,18 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
837
837
|
"annotation": "Annotate the page for documentation and teaching."
|
|
838
838
|
}
|
|
839
839
|
}
|
|
840
|
+
},
|
|
841
|
+
"interactions": {
|
|
842
|
+
"contract": "xcsh.interaction.v1",
|
|
843
|
+
"waiting": "request_user_input",
|
|
844
|
+
"asynchronous": "request_user_input_async",
|
|
845
|
+
"planActions": [
|
|
846
|
+
"implement",
|
|
847
|
+
"fresh",
|
|
848
|
+
"stay"
|
|
849
|
+
],
|
|
850
|
+
"snapshot": true,
|
|
851
|
+
"responseReceipts": true
|
|
840
852
|
}
|
|
841
853
|
}
|
|
842
854
|
};
|
|
@@ -421,7 +421,7 @@
|
|
|
421
421
|
},
|
|
422
422
|
{
|
|
423
423
|
"name": "query_dom",
|
|
424
|
-
"summary": "Direct DOM.querySelector at wire speed
|
|
424
|
+
"summary": "Direct DOM.querySelector at wire speed \u2014 bypasses Runtime.evaluate for simple CSS selectors.",
|
|
425
425
|
"category": "read",
|
|
426
426
|
"params": {
|
|
427
427
|
"type": "object",
|
|
@@ -584,7 +584,7 @@
|
|
|
584
584
|
},
|
|
585
585
|
{
|
|
586
586
|
"name": "diag_ttft",
|
|
587
|
-
"summary": "Diagnostic: init
|
|
587
|
+
"summary": "Diagnostic: init\u2192first-token timeline (per-stage ms, total, dominant, cold/warm).",
|
|
588
588
|
"category": "read",
|
|
589
589
|
"params": {
|
|
590
590
|
"type": "object",
|
|
@@ -675,7 +675,7 @@
|
|
|
675
675
|
},
|
|
676
676
|
{
|
|
677
677
|
"name": "set_explain_mode",
|
|
678
|
-
"summary": "Enter/leave explain mode
|
|
678
|
+
"summary": "Enter/leave explain mode \u2014 the gate for all on-page annotation overlays.",
|
|
679
679
|
"category": "annotation",
|
|
680
680
|
"params": {
|
|
681
681
|
"type": "object",
|
|
@@ -740,7 +740,7 @@
|
|
|
740
740
|
},
|
|
741
741
|
"capture": {
|
|
742
742
|
"tool": "screenshot",
|
|
743
|
-
"description": "Capture a screenshot (base64 PNG)
|
|
743
|
+
"description": "Capture a screenshot (base64 PNG) \u2014 for headless annotated-capture flows."
|
|
744
744
|
},
|
|
745
745
|
"viewport": {
|
|
746
746
|
"tool": "resize_window",
|
|
@@ -777,12 +777,12 @@
|
|
|
777
777
|
"media_asset_chunk",
|
|
778
778
|
"media_asset_error"
|
|
779
779
|
],
|
|
780
|
-
"description": "User
|
|
780
|
+
"description": "User \u2194 xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams ordered assistant items using chat_message_start, correlated chat_delta tokens, and chat_message_end. Each item declares commentary or final_answer phase. Exactly one terminal chat_done (with reference links) or chat_error closes the whole turn. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn \u2014 it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel. Rich media uses chat_media descriptors and bounded media_asset_read/media_asset_chunk exchanges; media_asset_error reports unavailable assets.",
|
|
781
781
|
"promptHints": {
|
|
782
782
|
"role": "You are xcsh, the AI assistant embedded in the F5 Distributed Cloud (XC) console side panel. The user is viewing a live console page; help them drive automation and understand settings and their purpose.",
|
|
783
|
-
"grounding": "Every user message carries a page-context snapshot: url/path, title, a trimmed accessibility tree, and
|
|
783
|
+
"grounding": "Every user message carries a page-context snapshot: url/path, title, a trimmed accessibility tree, and \u2014 when available \u2014 the live XC API resource JSON the page loaded (context.api.body). Treat context.api.body as the authoritative current state of the resource and ground answers in it instead of guessing; respect the truncated flags.",
|
|
784
784
|
"referenceLinks": "Emit every citation as a markdown link [title](url): F5 XC docs (docs.cloud.f5.com) as doc references and the tenant console host as console deep-links. These populate the panel References drawer, so always format references this way rather than as bare URLs.",
|
|
785
|
-
"toolUse": "Drive the console with the extension tools via the normal tool_request flow (navigate, click, annotate, screenshot, get_page_context, and so on). The extension surfaces tool activity to the panel itself (chat_tool_notice)
|
|
785
|
+
"toolUse": "Drive the console with the extension tools via the normal tool_request flow (navigate, click, annotate, screenshot, get_page_context, and so on). The extension surfaces tool activity to the panel itself (chat_tool_notice) \u2014 never emit chat_tool_notice yourself. Each turn ends with exactly one terminal frame, which the transport handles.",
|
|
786
786
|
"modes": {
|
|
787
787
|
"educational": "Explain concepts and answer questions about settings and their purpose.",
|
|
788
788
|
"presentation": "Guided, human-paced walkthrough/demo of the console.",
|
|
@@ -791,6 +791,18 @@
|
|
|
791
791
|
"annotation": "Annotate the page for documentation and teaching."
|
|
792
792
|
}
|
|
793
793
|
}
|
|
794
|
+
},
|
|
795
|
+
"interactions": {
|
|
796
|
+
"contract": "xcsh.interaction.v1",
|
|
797
|
+
"waiting": "request_user_input",
|
|
798
|
+
"asynchronous": "request_user_input_async",
|
|
799
|
+
"planActions": [
|
|
800
|
+
"implement",
|
|
801
|
+
"fresh",
|
|
802
|
+
"stay"
|
|
803
|
+
],
|
|
804
|
+
"snapshot": true,
|
|
805
|
+
"responseReceipts": true
|
|
794
806
|
}
|
|
795
807
|
}
|
|
796
808
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AssistantMessage, AssistantMessagePhase, ImageContent } from "@f5-sales-demo/pi-ai";
|
|
4
|
+
import { isInteractionCommand } from "../../../chat-ui/src/interactions/transport";
|
|
4
5
|
import { parseModelString } from "../config/model-resolver";
|
|
5
6
|
import { settings } from "../config/settings";
|
|
6
7
|
import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
|
|
@@ -112,6 +113,7 @@ export class ChatHandler {
|
|
|
112
113
|
// and awaits the correlated `host_tool_result`. Reused verbatim from the stdio RPC
|
|
113
114
|
// driver — the only WS-specific wiring is the `send()` output sink below.
|
|
114
115
|
#hostToolBridge: RpcHostToolBridge;
|
|
116
|
+
#unsubscribeInteractions?: () => void;
|
|
115
117
|
|
|
116
118
|
constructor(server: BridgeServer, session: AgentSession) {
|
|
117
119
|
this.#server = server;
|
|
@@ -126,7 +128,50 @@ export class ChatHandler {
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
attach(): void {
|
|
131
|
+
this.#unsubscribeInteractions = this.#session.subscribe(event => {
|
|
132
|
+
if (event.type === "interaction")
|
|
133
|
+
this.#server.send({ type: "interaction_event", revision: event.event.revision, event: event.event });
|
|
134
|
+
if (event.type === "interaction_snapshot") this.#server.send(event);
|
|
135
|
+
if (event.type === "plan_available" || event.type === "plan_resolved") this.#server.send(event);
|
|
136
|
+
});
|
|
129
137
|
this.#server.onMessage(msg => {
|
|
138
|
+
if (msg.type === "interaction_snapshot") {
|
|
139
|
+
this.#server.send({
|
|
140
|
+
type: "interaction_snapshot",
|
|
141
|
+
sessionId: this.#session.sessionId,
|
|
142
|
+
...this.#session.userInteractions.snapshot(),
|
|
143
|
+
plan: this.#session.conversationPlans.current,
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (isInteractionCommand(msg) && (msg.type === "interaction_respond" || msg.type === "interaction_cancel")) {
|
|
148
|
+
if (msg.type === "interaction_respond") {
|
|
149
|
+
const accepted = this.#session.userInteractions.respondExternal(
|
|
150
|
+
msg.requestId,
|
|
151
|
+
msg.responseId,
|
|
152
|
+
msg.value,
|
|
153
|
+
msg.identity,
|
|
154
|
+
);
|
|
155
|
+
this.#server.send({ type: "interaction_receipt", responseId: msg.responseId, accepted });
|
|
156
|
+
} else {
|
|
157
|
+
const request = this.#session.userInteractions.pending().find(request => request.id === msg.requestId);
|
|
158
|
+
const matches =
|
|
159
|
+
request?.identity &&
|
|
160
|
+
Object.entries(request.identity).every(
|
|
161
|
+
([key, value]) => msg.identity[key as keyof typeof msg.identity] === value,
|
|
162
|
+
);
|
|
163
|
+
if (matches && this.#session.userInteractions.resolve(msg.requestId, "interrupted"))
|
|
164
|
+
void this.#session.abort();
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (isInteractionCommand(msg) && msg.type === "plan_decide") {
|
|
169
|
+
void this.#session.decidePlan(msg.planId, msg.action).then(
|
|
170
|
+
receipt => this.#server.send({ type: "interaction_receipt", responseId: msg.responseId, ...receipt }),
|
|
171
|
+
() => this.#server.send({ type: "interaction_receipt", responseId: msg.responseId, accepted: false }),
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
130
175
|
if (this.#server.serveKind === "browser" && isBrowserChatRequest(msg)) this.#handleChatRequest(msg);
|
|
131
176
|
else if (this.#server.serveKind === "office" && isTransportChatRequest(msg)) this.#handleChatRequest(msg);
|
|
132
177
|
else if (isChatStop(msg)) this.#handleChatStop(msg as unknown as { id: string });
|
|
@@ -655,6 +700,7 @@ export class ChatHandler {
|
|
|
655
700
|
}
|
|
656
701
|
|
|
657
702
|
dispose(): void {
|
|
703
|
+
this.#unsubscribeInteractions?.();
|
|
658
704
|
this.#disposed = true;
|
|
659
705
|
this.#pendingRequest = null; // abandon any queued prompt — don't replay into a dead session
|
|
660
706
|
// Fail any in-flight host-tool call — the session is going away.
|
|
@@ -112,8 +112,8 @@ export const BROWSER_TOOL_NAMES: readonly string[] = [
|
|
|
112
112
|
* DELIBERATELY EXCLUDED:
|
|
113
113
|
* - Every {@link BROWSER_TOOL_NAMES} entry — there is no browser to drive in a
|
|
114
114
|
* document pane, so navigate/click/screenshot would only be hallucinated.
|
|
115
|
-
* - `
|
|
116
|
-
*
|
|
115
|
+
* - `python` (spawns a kernel → startup cost),
|
|
116
|
+
* `ssh`/`debug`/`notebook`/`browser`/`get_page_context`.
|
|
117
117
|
*
|
|
118
118
|
* SAFETY: the headless session pairs this with the bundled `sandbox-guard`
|
|
119
119
|
* extension (see headless-bridge.ts `bundledExtensions`), which confines the file
|
|
@@ -145,4 +145,6 @@ export const OFFICE_TOOL_NAMES: readonly string[] = [
|
|
|
145
145
|
"task",
|
|
146
146
|
"calc",
|
|
147
147
|
"inspect_image",
|
|
148
|
+
"request_user_input",
|
|
149
|
+
"request_user_input_async",
|
|
148
150
|
];
|
package/src/cli/args.ts
CHANGED
|
@@ -470,7 +470,8 @@ ${chalk.bold("Available Tools (default-enabled unless noted):")}
|
|
|
470
470
|
task - Launch sub-agents for parallel tasks
|
|
471
471
|
todo_write - Manage todo/task lists
|
|
472
472
|
web_search - Search the web
|
|
473
|
-
|
|
473
|
+
request_user_input - Ask structured questions and wait (Plan mode by default)
|
|
474
|
+
request_user_input_async - Ask questions without blocking ongoing work
|
|
474
475
|
|
|
475
476
|
${chalk.bold("Sandbox Options:")}
|
|
476
477
|
--no-sandbox ${LAUNCH_FLAGS["no-sandbox"].description}
|
|
@@ -951,24 +951,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
951
951
|
ui: { tab: "interaction", label: "Completion Notification", description: "Notify when the agent completes" },
|
|
952
952
|
},
|
|
953
953
|
|
|
954
|
-
"
|
|
955
|
-
type: "
|
|
956
|
-
default:
|
|
954
|
+
"interactions.waitingInDefault": {
|
|
955
|
+
type: "boolean",
|
|
956
|
+
default: false,
|
|
957
957
|
ui: {
|
|
958
958
|
tab: "interaction",
|
|
959
|
-
label: "
|
|
960
|
-
description: "
|
|
961
|
-
submenu: true,
|
|
959
|
+
label: "Waiting questions in Default mode",
|
|
960
|
+
description: "Allow request_user_input outside Plan mode",
|
|
962
961
|
},
|
|
963
962
|
},
|
|
964
963
|
|
|
965
|
-
"ask.notify": {
|
|
966
|
-
type: "enum",
|
|
967
|
-
values: ["on", "off"] as const,
|
|
968
|
-
default: "on",
|
|
969
|
-
ui: { tab: "interaction", label: "Ask Notification", description: "Notify when ask tool is waiting for input" },
|
|
970
|
-
},
|
|
971
|
-
|
|
972
964
|
// Speech-to-text
|
|
973
965
|
"stt.enabled": {
|
|
974
966
|
type: "boolean",
|
package/src/config/settings.ts
CHANGED
|
@@ -572,14 +572,6 @@ export class Settings {
|
|
|
572
572
|
delete raw.queueMode;
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
-
// ask.timeout: ms -> seconds (if value > 1000, it's old ms format)
|
|
576
|
-
if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
|
|
577
|
-
const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
|
|
578
|
-
if (oldValue > 1000) {
|
|
579
|
-
(raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
575
|
// Migrate old flat "theme" string to nested theme.dark/theme.light
|
|
584
576
|
if (typeof raw.theme === "string") {
|
|
585
577
|
const oldTheme = raw.theme;
|
|
@@ -173,7 +173,7 @@ function nativeCapability(): string | undefined {
|
|
|
173
173
|
|
|
174
174
|
/** Protocol 23 adds a workspace receipt without changing native action semantics. */
|
|
175
175
|
function supportsNativeLifecycle(protocol: number | undefined): boolean {
|
|
176
|
-
return protocol
|
|
176
|
+
return protocol !== undefined && protocol >= 22 && protocol <= 25;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
function persistedTurns(ctx: ExtensionContext): PersistedTurn[] {
|
|
@@ -962,7 +962,7 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
962
962
|
if (!normalizedPhasesObserved) scheduleSettledTurnReconcile(ctx);
|
|
963
963
|
});
|
|
964
964
|
|
|
965
|
-
// An interactive prompt (permission gate,
|
|
965
|
+
// An interactive prompt (permission gate, waiting user-input tool, confirm/input) is
|
|
966
966
|
// awaiting the user: that is herdr's "needs attention" (blocked) state.
|
|
967
967
|
pi.on("user_prompt_start", async event => {
|
|
968
968
|
promptBlockedReason = getPromptBlockedReason(event.kind);
|
|
@@ -39,7 +39,6 @@ import type { PythonResult } from "../../ipy/executor";
|
|
|
39
39
|
import type { Theme } from "../../modes/theme/theme";
|
|
40
40
|
import type { CompactionPreparation, CompactionResult } from "../../session/compaction";
|
|
41
41
|
import type { CustomMessage } from "../../session/messages";
|
|
42
|
-
import type { InteractionQuestion, QuestionAnswers } from "../../session/question-types";
|
|
43
42
|
import type {
|
|
44
43
|
BranchSummaryEntry,
|
|
45
44
|
CompactionEntry,
|
|
@@ -112,12 +111,6 @@ export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | un
|
|
|
112
111
|
* Each mode (interactive, RPC, print) provides its own implementation.
|
|
113
112
|
*/
|
|
114
113
|
export interface ExtensionUIContext {
|
|
115
|
-
/** Present a whole question set through one shared completion owner when supported. */
|
|
116
|
-
questions?(
|
|
117
|
-
questions: readonly InteractionQuestion[],
|
|
118
|
-
dialogOptions?: ExtensionUIDialogOptions,
|
|
119
|
-
): Promise<QuestionAnswers | undefined>;
|
|
120
|
-
|
|
121
114
|
/** Show a selector and return the user's choice. */
|
|
122
115
|
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
|
123
116
|
|
|
@@ -555,7 +548,7 @@ export interface MessageEndEvent {
|
|
|
555
548
|
export type UserPromptKind = "select" | "confirm" | "input";
|
|
556
549
|
|
|
557
550
|
/**
|
|
558
|
-
* Fired when an interactive prompt (permission gate,
|
|
551
|
+
* Fired when an interactive prompt (permission gate, waiting user-input tool, confirm/input
|
|
559
552
|
* dialog) is shown and is awaiting the user. Signals a "blocked / needs
|
|
560
553
|
* attention" state that is otherwise not observable from the agent event stream,
|
|
561
554
|
* since the session stays `isStreaming` while a prompt is open.
|
package/src/herdr/client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { connect } from "node:net";
|
|
3
3
|
|
|
4
4
|
export const HERDR_PROTOCOL_MIN_VERSION = 19;
|
|
5
|
-
export const HERDR_PROTOCOL_MAX_VERSION =
|
|
5
|
+
export const HERDR_PROTOCOL_MAX_VERSION = 25;
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
7
7
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
8
8
|
|
|
@@ -61,6 +61,11 @@ export class HerdrClient {
|
|
|
61
61
|
return this.negotiatedProtocol;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
capabilityVersion(name: string): number | undefined {
|
|
65
|
+
const version = this.capabilities[name];
|
|
66
|
+
return typeof version === "number" && Number.isSafeInteger(version) ? version : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
64
69
|
hasCapability(name: string): boolean {
|
|
65
70
|
return this.capabilities[name] === true;
|
|
66
71
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import type { ConversationPlan, PlanAction } from "../../../chat-ui/src/interactions/conversation-plan";
|
|
3
|
+
import type {
|
|
4
|
+
InteractionIdentity,
|
|
5
|
+
UserInteraction,
|
|
6
|
+
UserInteractionEvent,
|
|
7
|
+
UserInteractions,
|
|
8
|
+
} from "../session/user-interactions";
|
|
9
|
+
|
|
10
|
+
interface Client {
|
|
11
|
+
ensureProtocol(): Promise<void>;
|
|
12
|
+
capabilityVersion(name: string): number | undefined;
|
|
13
|
+
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
14
|
+
}
|
|
15
|
+
interface Binding {
|
|
16
|
+
execution_id: string;
|
|
17
|
+
pane_id: string;
|
|
18
|
+
producer: string;
|
|
19
|
+
generation: number;
|
|
20
|
+
}
|
|
21
|
+
interface Owner extends Binding {
|
|
22
|
+
session_id: string;
|
|
23
|
+
}
|
|
24
|
+
interface Target {
|
|
25
|
+
owner: Owner;
|
|
26
|
+
request_id: string;
|
|
27
|
+
}
|
|
28
|
+
interface Entry {
|
|
29
|
+
target: Target;
|
|
30
|
+
identity: InteractionIdentity;
|
|
31
|
+
report: Record<string, unknown>;
|
|
32
|
+
decide?: (action: PlanAction) => Promise<{ accepted: boolean }>;
|
|
33
|
+
}
|
|
34
|
+
const record = (value: unknown): value is Record<string, unknown> =>
|
|
35
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
36
|
+
|
|
37
|
+
/** Additional observation never owns completion or disables the local interaction path. */
|
|
38
|
+
export class HerdrInteractionBridge {
|
|
39
|
+
#entries = new Map<string, Entry>();
|
|
40
|
+
#reports: Record<string, unknown>[] = [];
|
|
41
|
+
#acks: Record<string, unknown>[] = [];
|
|
42
|
+
#decisions = new Map<string, { action: unknown; accepted: boolean }>();
|
|
43
|
+
#unsubscribe: () => void;
|
|
44
|
+
#timer?: ReturnType<typeof setInterval>;
|
|
45
|
+
#flight?: Promise<void>;
|
|
46
|
+
#closed = false;
|
|
47
|
+
#failureReported = false;
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly client: Client,
|
|
50
|
+
private readonly interactions: UserInteractions,
|
|
51
|
+
private readonly binding: Binding,
|
|
52
|
+
private readonly onError: (error: unknown) => void,
|
|
53
|
+
private readonly capability?: string,
|
|
54
|
+
poll = true,
|
|
55
|
+
) {
|
|
56
|
+
this.#unsubscribe = interactions.subscribe(event => this.#observe(event));
|
|
57
|
+
for (const request of interactions.pending()) this.#open(request, 1);
|
|
58
|
+
if (poll) {
|
|
59
|
+
this.#timer = setInterval(() => {
|
|
60
|
+
void this.flush();
|
|
61
|
+
}, 1000);
|
|
62
|
+
this.#timer.unref?.();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
#observe(event: UserInteractionEvent): void {
|
|
66
|
+
if (event.type === "opened") this.#open(event.interaction, event.revision);
|
|
67
|
+
else {
|
|
68
|
+
const entry = this.#entries.get(event.interaction.id);
|
|
69
|
+
if (!entry) return;
|
|
70
|
+
entry.report = { ...entry.report, event_revision: event.revision, state: event.reason ?? "owner_lost" };
|
|
71
|
+
this.#reports.push(structuredClone(entry.report));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
#open(request: UserInteraction, revision: number): void {
|
|
75
|
+
const identity = request.identity;
|
|
76
|
+
if (!identity || (request.kind !== "request_user_input" && request.delivery !== "async")) return;
|
|
77
|
+
const target = { owner: { ...this.binding, session_id: identity.sessionId }, request_id: request.id };
|
|
78
|
+
const report = {
|
|
79
|
+
target,
|
|
80
|
+
thread_id: identity.threadId,
|
|
81
|
+
turn_id: identity.turnId,
|
|
82
|
+
item_id: identity.itemId,
|
|
83
|
+
question_ids: request.inputQuestions?.map(question => question.id) ?? [request.questionId ?? identity.itemId],
|
|
84
|
+
event_revision: revision,
|
|
85
|
+
kind: request.delivery === "async" ? "async" : "waiting",
|
|
86
|
+
state: "pending",
|
|
87
|
+
payload:
|
|
88
|
+
request.delivery === "async"
|
|
89
|
+
? {
|
|
90
|
+
id: identity.itemId,
|
|
91
|
+
type: "agentMessage",
|
|
92
|
+
text: request.title,
|
|
93
|
+
phase: "final_answer",
|
|
94
|
+
delivery: "async",
|
|
95
|
+
questions: [{ title: request.title, ...(request.options ? { options: request.options } : {}) }],
|
|
96
|
+
}
|
|
97
|
+
: { questions: request.inputQuestions, isBlocking: true, autoResolutionMs: null },
|
|
98
|
+
};
|
|
99
|
+
this.#entries.set(request.id, { target, identity: structuredClone(identity), report });
|
|
100
|
+
this.#reports.push(structuredClone(report));
|
|
101
|
+
}
|
|
102
|
+
plan(
|
|
103
|
+
plan: ConversationPlan,
|
|
104
|
+
identity: InteractionIdentity,
|
|
105
|
+
decide: (action: PlanAction) => Promise<{ accepted: boolean }>,
|
|
106
|
+
): void {
|
|
107
|
+
const target = { owner: { ...this.binding, session_id: identity.sessionId }, request_id: plan.id };
|
|
108
|
+
const report = {
|
|
109
|
+
target,
|
|
110
|
+
thread_id: identity.threadId,
|
|
111
|
+
turn_id: identity.turnId,
|
|
112
|
+
item_id: plan.itemId,
|
|
113
|
+
question_ids: [],
|
|
114
|
+
event_revision: plan.revision,
|
|
115
|
+
kind: "plan_decision",
|
|
116
|
+
payload: plan,
|
|
117
|
+
state: "pending",
|
|
118
|
+
};
|
|
119
|
+
this.#entries.set(plan.id, { target, identity, report, decide });
|
|
120
|
+
this.#reports.push(structuredClone(report));
|
|
121
|
+
}
|
|
122
|
+
resolvePlan(planId: string): void {
|
|
123
|
+
const entry = this.#entries.get(planId);
|
|
124
|
+
if (!entry) return;
|
|
125
|
+
entry.report = { ...entry.report, event_revision: Number(entry.report.event_revision) + 1, state: "answered" };
|
|
126
|
+
this.#reports.push(structuredClone(entry.report));
|
|
127
|
+
}
|
|
128
|
+
flush(): Promise<void> {
|
|
129
|
+
if (this.#closed) return Promise.resolve();
|
|
130
|
+
if (this.#flight) return this.#flight;
|
|
131
|
+
this.#flight = this.#flush()
|
|
132
|
+
.catch(error => {
|
|
133
|
+
if (!this.#failureReported) this.onError(error);
|
|
134
|
+
this.#failureReported = true;
|
|
135
|
+
})
|
|
136
|
+
.finally(() => {
|
|
137
|
+
this.#flight = undefined;
|
|
138
|
+
});
|
|
139
|
+
return this.#flight;
|
|
140
|
+
}
|
|
141
|
+
async #flush(): Promise<void> {
|
|
142
|
+
if (!this.#entries.size) return;
|
|
143
|
+
await this.client.ensureProtocol();
|
|
144
|
+
if (this.client.capabilityVersion("agent_interactions") !== 1)
|
|
145
|
+
throw new Error("Herdr interaction capability is unavailable");
|
|
146
|
+
await this.#flushAcknowledgements();
|
|
147
|
+
while (this.#reports.length) {
|
|
148
|
+
const report = this.#reports[0];
|
|
149
|
+
const response = await this.client.request("agent.interaction.report", {
|
|
150
|
+
...report,
|
|
151
|
+
...(this.capability ? { native_capability: this.capability } : {}),
|
|
152
|
+
});
|
|
153
|
+
if (response.type !== "agent_interaction") throw new Error("Herdr did not acknowledge the interaction report");
|
|
154
|
+
this.#reports.shift();
|
|
155
|
+
}
|
|
156
|
+
const owners = new Map(
|
|
157
|
+
[...this.#entries.values()]
|
|
158
|
+
.filter(entry => entry.report.state === "pending")
|
|
159
|
+
.map(entry => [JSON.stringify(entry.target.owner), entry.target.owner]),
|
|
160
|
+
);
|
|
161
|
+
for (const owner of owners.values()) {
|
|
162
|
+
const producer = { owner, ...(this.capability ? { native_capability: this.capability } : {}) };
|
|
163
|
+
const response = await this.client.request("agent.interaction.delivery.get", producer);
|
|
164
|
+
if (response.type !== "agent_interaction_deliveries" || !Array.isArray(response.deliveries))
|
|
165
|
+
throw new Error("Invalid Herdr delivery response");
|
|
166
|
+
for (const delivery of response.deliveries) {
|
|
167
|
+
if (
|
|
168
|
+
!record(delivery) ||
|
|
169
|
+
!record(delivery.receipt) ||
|
|
170
|
+
!record(delivery.receipt.target) ||
|
|
171
|
+
typeof delivery.receipt.response_id !== "string"
|
|
172
|
+
)
|
|
173
|
+
throw new Error("Invalid Herdr delivery identity");
|
|
174
|
+
const receipt = delivery.receipt;
|
|
175
|
+
const target = receipt.target as unknown as Target;
|
|
176
|
+
const entry = this.#entries.get(target.request_id);
|
|
177
|
+
if (!entry || !isDeepStrictEqual(target, entry.target)) throw new Error("Herdr delivery owner mismatch");
|
|
178
|
+
let accepted: boolean;
|
|
179
|
+
if (entry.decide) {
|
|
180
|
+
const prior = this.#decisions.get(receipt.response_id as string);
|
|
181
|
+
if (prior) accepted = prior.action === delivery.answer && prior.accepted;
|
|
182
|
+
else {
|
|
183
|
+
accepted =
|
|
184
|
+
typeof delivery.answer === "string" &&
|
|
185
|
+
["implement", "fresh", "stay"].includes(delivery.answer) &&
|
|
186
|
+
(await entry.decide(delivery.answer as PlanAction)).accepted;
|
|
187
|
+
this.#decisions.set(receipt.response_id as string, { action: delivery.answer, accepted });
|
|
188
|
+
}
|
|
189
|
+
} else
|
|
190
|
+
accepted = this.interactions.respondExternal(
|
|
191
|
+
target.request_id,
|
|
192
|
+
receipt.response_id as string,
|
|
193
|
+
delivery.answer,
|
|
194
|
+
entry.identity,
|
|
195
|
+
);
|
|
196
|
+
this.#acks.push({
|
|
197
|
+
producer,
|
|
198
|
+
request_id: target.request_id,
|
|
199
|
+
response_id: receipt.response_id,
|
|
200
|
+
accepted,
|
|
201
|
+
});
|
|
202
|
+
await this.#flushAcknowledgements();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.#failureReported = false;
|
|
206
|
+
}
|
|
207
|
+
async #flushAcknowledgements(): Promise<void> {
|
|
208
|
+
while (this.#acks.length) {
|
|
209
|
+
const ack = await this.client.request("agent.interaction.delivery.ack", this.#acks[0]);
|
|
210
|
+
if (ack.type !== "agent_interaction_receipt")
|
|
211
|
+
throw new Error("Herdr did not acknowledge producer answer delivery");
|
|
212
|
+
this.#acks.shift();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async close(): Promise<void> {
|
|
216
|
+
if (this.#timer) clearInterval(this.#timer);
|
|
217
|
+
this.#unsubscribe();
|
|
218
|
+
await this.flush();
|
|
219
|
+
this.#closed = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ApiCatalogCategory, ApiCatalogIndex } from "./api-catalog-types";
|
|
3
|
+
|
|
4
|
+
export interface ApiCatalogDiscoveryDocument {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly categoryName: string;
|
|
7
|
+
readonly markdown: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ApiCatalogDiscoveryCorpus {
|
|
11
|
+
readonly catalogVersion: string;
|
|
12
|
+
readonly documents: readonly ApiCatalogDiscoveryDocument[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ApiCatalogDiscoveryCandidate {
|
|
16
|
+
readonly categoryName: string;
|
|
17
|
+
readonly destination: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeApiCatalogDiscoveryTerm(value: string): string {
|
|
21
|
+
return value.toLowerCase().replace(/[_\s]+/g, "-");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The legacy text predicate extracted without changing its semantics. */
|
|
25
|
+
export function matchesBaselineCatalogDiscoveryTerm(term: string, values: readonly string[]): boolean {
|
|
26
|
+
const normalized = normalizeApiCatalogDiscoveryTerm(term);
|
|
27
|
+
return values.some(value => normalizeApiCatalogDiscoveryTerm(value).includes(normalized));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function categoryDocument(category: ApiCatalogCategory): ApiCatalogDiscoveryDocument {
|
|
31
|
+
const destination = `xcsh://api-catalog/${category.name}`;
|
|
32
|
+
const operations = category.operations.flatMap(operation => [
|
|
33
|
+
`- Operation: ${operation.name}`,
|
|
34
|
+
` - Alias: ${(operation.operationAliases ?? []).join(", ")}`,
|
|
35
|
+
` - Description: ${operation.description}`,
|
|
36
|
+
` - Method: ${operation.method.toUpperCase()}`,
|
|
37
|
+
` - Path: ${operation.path}`,
|
|
38
|
+
` - Operation ID: ${operation.operationId}`,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: `category:${category.name}`,
|
|
43
|
+
categoryName: category.name,
|
|
44
|
+
markdown: [
|
|
45
|
+
`# ${category.displayName}`,
|
|
46
|
+
"",
|
|
47
|
+
`- Category: ${category.name}`,
|
|
48
|
+
`- Destination: ${destination}`,
|
|
49
|
+
"",
|
|
50
|
+
"## Operations",
|
|
51
|
+
...(operations.length > 0 ? operations : ["- None"]),
|
|
52
|
+
"",
|
|
53
|
+
].join("\n"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Produces a stable, authoritative-only corpus. It intentionally contains no
|
|
59
|
+
* tenant data, credentials, generated examples, or speculative API fields.
|
|
60
|
+
*/
|
|
61
|
+
export function buildApiCatalogDiscoveryCorpus(
|
|
62
|
+
index: ApiCatalogIndex,
|
|
63
|
+
data: Readonly<Record<string, ApiCatalogCategory>>,
|
|
64
|
+
): ApiCatalogDiscoveryCorpus {
|
|
65
|
+
return {
|
|
66
|
+
catalogVersion: index.version,
|
|
67
|
+
documents: Object.values(data)
|
|
68
|
+
.slice()
|
|
69
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
70
|
+
.map(categoryDocument),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function fingerprintApiCatalogDiscoveryCorpus(
|
|
75
|
+
corpus: ApiCatalogDiscoveryCorpus,
|
|
76
|
+
sourceSha: string,
|
|
77
|
+
engineVersion: string,
|
|
78
|
+
): string {
|
|
79
|
+
const bytes = JSON.stringify({
|
|
80
|
+
sourceSha,
|
|
81
|
+
catalogVersion: corpus.catalogVersion,
|
|
82
|
+
engineVersion,
|
|
83
|
+
documents: corpus.documents,
|
|
84
|
+
});
|
|
85
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Byte-equivalent baseline candidate selection for the existing renderer.
|
|
90
|
+
* Ranking is intentionally unchanged: canonical CRUD promotion stays in the
|
|
91
|
+
* renderer where it has access to API-spec evidence.
|
|
92
|
+
*/
|
|
93
|
+
export function rankBaselineCatalogDiscovery(
|
|
94
|
+
term: string,
|
|
95
|
+
corpus: ApiCatalogDiscoveryCorpus,
|
|
96
|
+
): readonly ApiCatalogDiscoveryCandidate[] {
|
|
97
|
+
return corpus.documents
|
|
98
|
+
.filter(document => matchesBaselineCatalogDiscoveryTerm(term, [document.markdown]))
|
|
99
|
+
.map(document => ({
|
|
100
|
+
categoryName: document.categoryName,
|
|
101
|
+
destination: `xcsh://api-catalog/${document.categoryName}`,
|
|
102
|
+
}));
|
|
103
|
+
}
|