dsh-ntr 0.3.3 → 0.3.4
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/lib/bridge.js +124 -19
- package/lib/impl.js +106 -16
- package/lib/types/bridge.d.ts +15 -0
- package/lib/types/impl.d.ts +8 -0
- package/package.json +1 -1
- package/src/bridge.ts +123 -17
- package/src/impl.ts +102 -17
package/lib/bridge.js
CHANGED
|
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
|
|
|
12
12
|
import { Writable, Readable } from 'node:stream';
|
|
13
13
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
15
|
-
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
15
|
+
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, hasUnambiguousModelSelector, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
16
16
|
import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
|
|
17
17
|
import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
|
|
18
18
|
/**
|
|
@@ -6287,6 +6287,11 @@ export class BridgeManager {
|
|
|
6287
6287
|
const def = this.defsMap.get(agentId);
|
|
6288
6288
|
if (def === undefined || def.autoDiscoverModels === false || before === undefined)
|
|
6289
6289
|
return false;
|
|
6290
|
+
// A different model-slot identity is a corrected reading, not newly added
|
|
6291
|
+
// models. Diffing across it would expose an entire real catalog the first
|
|
6292
|
+
// time a parser fix replaces a mislabelled slot (Qoder CN).
|
|
6293
|
+
if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId)
|
|
6294
|
+
return false;
|
|
6290
6295
|
const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
|
|
6291
6296
|
const exposed = new Set(def.exposedModels ?? []);
|
|
6292
6297
|
const added = (next.model?.choices ?? [])
|
|
@@ -6686,6 +6691,12 @@ export class BridgeManager {
|
|
|
6686
6691
|
const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined;
|
|
6687
6692
|
if (wanted === undefined)
|
|
6688
6693
|
return undefined;
|
|
6694
|
+
// `category` is a UX hint, not identity: an agent may publish several
|
|
6695
|
+
// `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
|
|
6696
|
+
// and no explicit `model` id. In that case the selector is a guess, so a
|
|
6697
|
+
// "not offered" verdict would refuse a perfectly valid pick — fail open.
|
|
6698
|
+
if (!hasUnambiguousModelSelector(session.configOptions))
|
|
6699
|
+
return undefined;
|
|
6689
6700
|
const model = parseCatalogOptions(session.configOptions).model;
|
|
6690
6701
|
if (model === undefined)
|
|
6691
6702
|
return undefined;
|
|
@@ -7630,6 +7641,44 @@ export class BridgeManager {
|
|
|
7630
7641
|
});
|
|
7631
7642
|
return { ok: true, reset: true };
|
|
7632
7643
|
}
|
|
7644
|
+
/**
|
|
7645
|
+
* 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
|
|
7646
|
+
* 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
|
|
7647
|
+
* 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
|
|
7648
|
+
*
|
|
7649
|
+
* 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
|
|
7650
|
+
* 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
|
|
7651
|
+
*
|
|
7652
|
+
* - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
|
|
7653
|
+
* - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
|
|
7654
|
+
* 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
|
|
7655
|
+
*/
|
|
7656
|
+
async reclaimChatPrompt(session, controller, signal) {
|
|
7657
|
+
if (session.closed || session.released)
|
|
7658
|
+
return 'ok';
|
|
7659
|
+
if (!controller.hasSettled && session.promptInFlight === true) {
|
|
7660
|
+
await this.bridge(session.agentId).cancel(session);
|
|
7661
|
+
controller.abortActiveSegment();
|
|
7662
|
+
}
|
|
7663
|
+
return this.waitForSettleGate(session, signal);
|
|
7664
|
+
}
|
|
7665
|
+
/** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
|
|
7666
|
+
waitForSettleGate(session, signal) {
|
|
7667
|
+
const pending = session.settled;
|
|
7668
|
+
if (pending === undefined)
|
|
7669
|
+
return Promise.resolve('ok');
|
|
7670
|
+
if (signal?.aborted)
|
|
7671
|
+
return Promise.resolve('aborted');
|
|
7672
|
+
return new Promise(resolve => {
|
|
7673
|
+
const done = (value) => {
|
|
7674
|
+
signal?.removeEventListener('abort', onAbort);
|
|
7675
|
+
resolve(value);
|
|
7676
|
+
};
|
|
7677
|
+
const onAbort = () => { done('aborted'); };
|
|
7678
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
7679
|
+
void pending.then(() => { done('ok'); });
|
|
7680
|
+
});
|
|
7681
|
+
}
|
|
7633
7682
|
async *streamPrompt(options) {
|
|
7634
7683
|
// The plugin's own conversation↔agent mapping decides who serves the turn
|
|
7635
7684
|
// (see `bindChatAgent`): the driver may arrive here with the previous
|
|
@@ -7837,26 +7886,82 @@ export class BridgeManager {
|
|
|
7837
7886
|
}
|
|
7838
7887
|
// One prompt slot per session, and a turn the user aborted still occupies
|
|
7839
7888
|
// the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
|
|
7840
|
-
|
|
7889
|
+
let claim = await bridge.claimTurn(session);
|
|
7841
7890
|
if (!claim.ok) {
|
|
7842
|
-
//
|
|
7843
|
-
//
|
|
7844
|
-
//
|
|
7845
|
-
//
|
|
7846
|
-
//
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7891
|
+
// A STEER ("插话"): the host delivers the user's mid-turn message as the
|
|
7892
|
+
// NEXT step of the SAME host turn, so the conversation's own ACP prompt
|
|
7893
|
+
// is still on air. Refusing would fail the whole host turn. ACP forbids
|
|
7894
|
+
// injecting into a running prompt, so the steer's real semantic — abort
|
|
7895
|
+
// the current turn, then send this one — is spelled cancel + wait +
|
|
7896
|
+
// re-claim here.
|
|
7897
|
+
//
|
|
7898
|
+
// Only `busy` can be a steer: it means the AGENT still holds OUR prompt
|
|
7899
|
+
// (`promptInFlight`). `unsettled` means our side already released the
|
|
7900
|
+
// slot (the person pressed stop, or an earlier steer cancelled): the
|
|
7901
|
+
// agent has not acknowledged yet, and quietly queueing the next message
|
|
7902
|
+
// behind a turn the user stopped is not this path's business — that
|
|
7903
|
+
// stays the honest `turn_in_flight` refusal below.
|
|
7904
|
+
//
|
|
7905
|
+
// Only the conversation's OWN in-flight turn qualifies (its live stream
|
|
7906
|
+
// controller, on this very session). A `/acp` RPC turn has no chat
|
|
7907
|
+
// controller, and another conversation never reaches this session, so
|
|
7908
|
+
// both keep the plain refusal: nothing may be cancelled behind a
|
|
7909
|
+
// caller's back.
|
|
7910
|
+
const chatController = claim.reason === 'busy' && options.sessionId !== undefined
|
|
7911
|
+
? this.turnStreamFor(options.sessionId)
|
|
7912
|
+
: undefined;
|
|
7913
|
+
if (options.sessionId !== undefined
|
|
7914
|
+
&& chatController !== undefined
|
|
7915
|
+
&& !chatController.stale
|
|
7916
|
+
&& chatController.session === session) {
|
|
7917
|
+
const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal);
|
|
7918
|
+
if (reclaimed === 'aborted') {
|
|
7919
|
+
// The person pressed stop while the previous turn was winding down:
|
|
7920
|
+
// the steer is theirs to re-send, nothing goes to the agent.
|
|
7921
|
+
yield {
|
|
7922
|
+
type: 'finish',
|
|
7923
|
+
reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
|
|
7924
|
+
};
|
|
7925
|
+
return;
|
|
7926
|
+
}
|
|
7927
|
+
// A first turn that produced nothing rolled its provisional lock back
|
|
7928
|
+
// and CLOSED (and forgot) this session: the steer needs a fresh one.
|
|
7929
|
+
if (session.closed || session.released) {
|
|
7930
|
+
try {
|
|
7931
|
+
session = await this.getOrCreateDshSession(options.sessionId, agentId);
|
|
7932
|
+
}
|
|
7933
|
+
catch (err) {
|
|
7934
|
+
yield {
|
|
7935
|
+
type: 'finish',
|
|
7936
|
+
reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
|
|
7937
|
+
};
|
|
7938
|
+
return;
|
|
7939
|
+
}
|
|
7940
|
+
}
|
|
7941
|
+
// Same bridge: the session may have been re-created, but the
|
|
7942
|
+
// conversation is still served by this agent's process.
|
|
7943
|
+
claim = await bridge.claimTurn(session);
|
|
7944
|
+
}
|
|
7945
|
+
if (!claim.ok) {
|
|
7946
|
+
// Never roll the provisional lock back here: a failed claim means
|
|
7947
|
+
// ANOTHER prompt holds the slot (or the agent has not settled it), and
|
|
7948
|
+
// that turn owns the conversation's lock. Closing the session would
|
|
7949
|
+
// kill a turn that is actively streaming — the refusal already tells
|
|
7950
|
+
// the user to retry.
|
|
7951
|
+
yield {
|
|
7952
|
+
type: 'finish',
|
|
7953
|
+
reason: {
|
|
7954
|
+
kind: 'error',
|
|
7955
|
+
failure: {
|
|
7956
|
+
message: claim.reason === 'busy'
|
|
7957
|
+
? 'acp: this session already has a prompt in flight'
|
|
7958
|
+
: 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
|
|
7959
|
+
code: 'turn_in_flight',
|
|
7960
|
+
},
|
|
7856
7961
|
},
|
|
7857
|
-
}
|
|
7858
|
-
|
|
7859
|
-
|
|
7962
|
+
};
|
|
7963
|
+
return;
|
|
7964
|
+
}
|
|
7860
7965
|
}
|
|
7861
7966
|
// Same-agent model/effort switches ride the SAME ACP session (context
|
|
7862
7967
|
// stays continuous); switching to another agent lands in a separate one
|
package/lib/impl.js
CHANGED
|
@@ -834,6 +834,42 @@ export function childEnv(parent, extra) {
|
|
|
834
834
|
return out;
|
|
835
835
|
}
|
|
836
836
|
// ---- Model catalog (pure parsing of ACP `configOptions`) ----
|
|
837
|
+
/**
|
|
838
|
+
* ACP categories are free-form UX hints, not identity. Qoder CN tags
|
|
839
|
+
* `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
|
|
840
|
+
* and nothing stops an agent from reusing a category for several options.
|
|
841
|
+
* Slots are therefore assigned by `configId` first and category second, and a
|
|
842
|
+
* same-category option NEVER replaces another option's slot: whatever loses
|
|
843
|
+
* stays reachable through `extras` instead of silently becoming the model list.
|
|
844
|
+
*/
|
|
845
|
+
const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i;
|
|
846
|
+
function isModelSelector(configId, name) {
|
|
847
|
+
return configId === 'model' || name.trim().toLowerCase() === 'model';
|
|
848
|
+
}
|
|
849
|
+
function catalogSlotFor(category, configId, name) {
|
|
850
|
+
const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name);
|
|
851
|
+
if (category === 'model') {
|
|
852
|
+
// The option explicitly named `model` is the selector even when other
|
|
853
|
+
// model-scoped options are present.
|
|
854
|
+
if (isModelSelector(configId, name))
|
|
855
|
+
return { slot: 'model', rank: 3 };
|
|
856
|
+
// A model-scoped effort is mislabelled, not a model: route it to the
|
|
857
|
+
// thought chip when no real `thought_level` option is published.
|
|
858
|
+
if (effortish)
|
|
859
|
+
return { slot: 'thought', rank: 1 };
|
|
860
|
+
// An unknown model-scoped select: a last-resort selector only.
|
|
861
|
+
return { slot: 'model', rank: 1 };
|
|
862
|
+
}
|
|
863
|
+
if (category === 'thought_level')
|
|
864
|
+
return { slot: 'thought', rank: 3 };
|
|
865
|
+
if (category === 'mode')
|
|
866
|
+
return { slot: 'mode', rank: 2 };
|
|
867
|
+
// `model_config` is where the spec puts model-dependent knobs (effort,
|
|
868
|
+
// context size); only the effort-shaped ones have a dedicated control.
|
|
869
|
+
if (category === 'model_config' && effortish)
|
|
870
|
+
return { slot: 'thought', rank: 1 };
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
837
873
|
/**
|
|
838
874
|
* Extract select options from a raw ACP `configOptions` payload.
|
|
839
875
|
*
|
|
@@ -848,19 +884,13 @@ export function parseCatalogOptions(configOptions) {
|
|
|
848
884
|
const out = {};
|
|
849
885
|
if (!Array.isArray(configOptions))
|
|
850
886
|
return out;
|
|
851
|
-
const
|
|
887
|
+
const definitions = [];
|
|
888
|
+
const candidates = [];
|
|
852
889
|
for (const raw of configOptions) {
|
|
853
890
|
if (typeof raw !== 'object' || raw === null)
|
|
854
891
|
continue;
|
|
855
892
|
const row = raw;
|
|
856
893
|
const category = typeof row.category === 'string' ? row.category : undefined;
|
|
857
|
-
const slot = category === 'model'
|
|
858
|
-
? 'model'
|
|
859
|
-
: category === 'thought_level'
|
|
860
|
-
? 'thought'
|
|
861
|
-
: category === 'mode'
|
|
862
|
-
? 'mode'
|
|
863
|
-
: undefined;
|
|
864
894
|
const configIdRaw = row.configId ?? row.id;
|
|
865
895
|
if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number')
|
|
866
896
|
continue;
|
|
@@ -869,6 +899,7 @@ export function parseCatalogOptions(configOptions) {
|
|
|
869
899
|
continue;
|
|
870
900
|
const choices = [];
|
|
871
901
|
collectCatalogChoices(row.options, choices);
|
|
902
|
+
const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId;
|
|
872
903
|
// A boolean toggle has no `options` at all; its current value is a JSON
|
|
873
904
|
// boolean. It is encoded as 'true' / 'false' so the catalog stays
|
|
874
905
|
// string-typed for every existing reader, and carries `kind` so the
|
|
@@ -877,7 +908,7 @@ export function parseCatalogOptions(configOptions) {
|
|
|
877
908
|
|| (typeof row.currentValue === 'boolean' && choices.length === 0);
|
|
878
909
|
const option = {
|
|
879
910
|
configId,
|
|
880
|
-
name
|
|
911
|
+
name,
|
|
881
912
|
...(booleanKind ? { kind: 'boolean' } : {}),
|
|
882
913
|
currentValue: booleanKind
|
|
883
914
|
? row.currentValue === true || row.currentValue === 'true'
|
|
@@ -886,22 +917,81 @@ export function parseCatalogOptions(configOptions) {
|
|
|
886
917
|
: typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
|
|
887
918
|
choices,
|
|
888
919
|
};
|
|
920
|
+
definitions.push(option);
|
|
889
921
|
// A toggle is never a model / thought / mode SELECT: those controls pick
|
|
890
922
|
// among choices. It belongs to the advanced base, where it is a checkbox.
|
|
891
|
-
if (
|
|
892
|
-
out[slot] = option;
|
|
923
|
+
if (booleanKind)
|
|
893
924
|
continue;
|
|
925
|
+
const slot = catalogSlotFor(category, configId, name);
|
|
926
|
+
if (slot !== undefined)
|
|
927
|
+
candidates.push({ ...slot, option });
|
|
928
|
+
}
|
|
929
|
+
const winnerIds = new Set();
|
|
930
|
+
for (const slot of ['model', 'thought', 'mode']) {
|
|
931
|
+
let winner;
|
|
932
|
+
for (const candidate of candidates) {
|
|
933
|
+
if (candidate.slot !== slot)
|
|
934
|
+
continue;
|
|
935
|
+
if (winner === undefined || candidate.rank > winner.rank) {
|
|
936
|
+
winner = candidate;
|
|
937
|
+
}
|
|
938
|
+
else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
|
|
939
|
+
// The same option redefined later in one answer: the last definition
|
|
940
|
+
// is the current one.
|
|
941
|
+
winner = candidate;
|
|
942
|
+
}
|
|
894
943
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
944
|
+
if (winner === undefined)
|
|
945
|
+
continue;
|
|
946
|
+
out[slot] = winner.option;
|
|
947
|
+
winnerIds.add(winner.option.configId);
|
|
948
|
+
}
|
|
949
|
+
// Choice-less options have nothing to pick; they would render an empty
|
|
950
|
+
// section in the advanced menu, so they stay out of the catalog entirely —
|
|
951
|
+
// except a boolean, whose whole point is that it has no choices. Anything
|
|
952
|
+
// that lost a slot race is still a real, settable option: keep it here.
|
|
953
|
+
const extras = new Map();
|
|
954
|
+
for (const option of definitions) {
|
|
955
|
+
if (winnerIds.has(option.configId))
|
|
956
|
+
continue;
|
|
957
|
+
if (option.choices.length > 0 || option.kind === 'boolean')
|
|
958
|
+
extras.set(option.configId, option);
|
|
900
959
|
}
|
|
901
960
|
if (extras.size > 0)
|
|
902
961
|
out.extras = [...extras.values()];
|
|
903
962
|
return out;
|
|
904
963
|
}
|
|
964
|
+
/**
|
|
965
|
+
* Whether a payload names ONE model selector without relying on category
|
|
966
|
+
* alone. When an agent publishes several `category: 'model'` options and none
|
|
967
|
+
* is explicitly the `model` id, the category is not trustworthy enough to
|
|
968
|
+
* reject the user's pick — callers must fail open instead of reporting
|
|
969
|
+
* `model_unavailable`.
|
|
970
|
+
*/
|
|
971
|
+
export function hasUnambiguousModelSelector(configOptions) {
|
|
972
|
+
if (!Array.isArray(configOptions))
|
|
973
|
+
return true;
|
|
974
|
+
let scoped = 0;
|
|
975
|
+
let explicit = false;
|
|
976
|
+
for (const raw of configOptions) {
|
|
977
|
+
if (typeof raw !== 'object' || raw === null)
|
|
978
|
+
continue;
|
|
979
|
+
const row = raw;
|
|
980
|
+
if (row.category !== 'model')
|
|
981
|
+
continue;
|
|
982
|
+
const configIdRaw = row.configId ?? row.id;
|
|
983
|
+
if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number')
|
|
984
|
+
continue;
|
|
985
|
+
const configId = String(configIdRaw);
|
|
986
|
+
if (configId === '')
|
|
987
|
+
continue;
|
|
988
|
+
scoped += 1;
|
|
989
|
+
const name = typeof row.name === 'string' ? row.name : '';
|
|
990
|
+
if (isModelSelector(configId, name))
|
|
991
|
+
explicit = true;
|
|
992
|
+
}
|
|
993
|
+
return explicit || scoped <= 1;
|
|
994
|
+
}
|
|
905
995
|
/**
|
|
906
996
|
* Fold one per-session option answer's SHAPE into an agent-level catalog.
|
|
907
997
|
*
|
package/lib/types/bridge.d.ts
CHANGED
|
@@ -2667,6 +2667,21 @@ export declare class BridgeManager {
|
|
|
2667
2667
|
* user is told the context is gone.
|
|
2668
2668
|
*/
|
|
2669
2669
|
private prepareStalledSession;
|
|
2670
|
+
/**
|
|
2671
|
+
* 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
|
|
2672
|
+
* 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
|
|
2673
|
+
* 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
|
|
2674
|
+
*
|
|
2675
|
+
* 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
|
|
2676
|
+
* 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
|
|
2677
|
+
*
|
|
2678
|
+
* - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
|
|
2679
|
+
* - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
|
|
2680
|
+
* 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
|
|
2681
|
+
*/
|
|
2682
|
+
private reclaimChatPrompt;
|
|
2683
|
+
/** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
|
|
2684
|
+
private waitForSettleGate;
|
|
2670
2685
|
streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
2671
2686
|
}
|
|
2672
2687
|
export declare class AcpLlmAdapter implements LlmAdapterLike {
|
package/lib/types/impl.d.ts
CHANGED
|
@@ -248,6 +248,14 @@ export declare function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string
|
|
|
248
248
|
* anything unusable is skipped silently.
|
|
249
249
|
*/
|
|
250
250
|
export declare function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog;
|
|
251
|
+
/**
|
|
252
|
+
* Whether a payload names ONE model selector without relying on category
|
|
253
|
+
* alone. When an agent publishes several `category: 'model'` options and none
|
|
254
|
+
* is explicitly the `model` id, the category is not trustworthy enough to
|
|
255
|
+
* reject the user's pick — callers must fail open instead of reporting
|
|
256
|
+
* `model_unavailable`.
|
|
257
|
+
*/
|
|
258
|
+
export declare function hasUnambiguousModelSelector(configOptions: unknown): boolean;
|
|
251
259
|
/**
|
|
252
260
|
* Fold one per-session option answer's SHAPE into an agent-level catalog.
|
|
253
261
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ntr",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
|
|
5
5
|
"repository": "https://github.com/GDWhisper/dsh-ntr.git",
|
|
6
6
|
"type": "module",
|
package/src/bridge.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
acpToolCallFromUpdate,
|
|
41
41
|
buildAdapterModelId,
|
|
42
42
|
childEnv,
|
|
43
|
+
hasUnambiguousModelSelector,
|
|
43
44
|
isAcpToolContinuation,
|
|
44
45
|
isUnderPath,
|
|
45
46
|
mergeCatalogShape,
|
|
@@ -6970,6 +6971,10 @@ export class BridgeManager {
|
|
|
6970
6971
|
private autoExposeNewModels(agentId: string, next: AcpAgentCatalog, before: AcpAgentCatalog | undefined): boolean {
|
|
6971
6972
|
const def = this.defsMap.get(agentId)
|
|
6972
6973
|
if (def === undefined || def.autoDiscoverModels === false || before === undefined) return false
|
|
6974
|
+
// A different model-slot identity is a corrected reading, not newly added
|
|
6975
|
+
// models. Diffing across it would expose an entire real catalog the first
|
|
6976
|
+
// time a parser fix replaces a mislabelled slot (Qoder CN).
|
|
6977
|
+
if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId) return false
|
|
6973
6978
|
const known = new Set((before.model?.choices ?? []).map(choice => choice.value))
|
|
6974
6979
|
const exposed = new Set(def.exposedModels ?? [])
|
|
6975
6980
|
const added = (next.model?.choices ?? [])
|
|
@@ -7360,6 +7365,11 @@ export class BridgeManager {
|
|
|
7360
7365
|
private staleModelChoice(session: LiveSession, modelChoice: string | undefined): string | undefined {
|
|
7361
7366
|
const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined
|
|
7362
7367
|
if (wanted === undefined) return undefined
|
|
7368
|
+
// `category` is a UX hint, not identity: an agent may publish several
|
|
7369
|
+
// `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
|
|
7370
|
+
// and no explicit `model` id. In that case the selector is a guess, so a
|
|
7371
|
+
// "not offered" verdict would refuse a perfectly valid pick — fail open.
|
|
7372
|
+
if (!hasUnambiguousModelSelector(session.configOptions)) return undefined
|
|
7363
7373
|
const model = parseCatalogOptions(session.configOptions).model
|
|
7364
7374
|
if (model === undefined) return undefined
|
|
7365
7375
|
if (model.currentValue === wanted) return undefined
|
|
@@ -8309,6 +8319,47 @@ export class BridgeManager {
|
|
|
8309
8319
|
return { ok: true, reset: true }
|
|
8310
8320
|
}
|
|
8311
8321
|
|
|
8322
|
+
/**
|
|
8323
|
+
* 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
|
|
8324
|
+
* 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
|
|
8325
|
+
* 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
|
|
8326
|
+
*
|
|
8327
|
+
* 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
|
|
8328
|
+
* 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
|
|
8329
|
+
*
|
|
8330
|
+
* - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
|
|
8331
|
+
* - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
|
|
8332
|
+
* 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
|
|
8333
|
+
*/
|
|
8334
|
+
private async reclaimChatPrompt(
|
|
8335
|
+
session: LiveSession,
|
|
8336
|
+
controller: TurnStreamController,
|
|
8337
|
+
signal?: AbortSignal,
|
|
8338
|
+
): Promise<'ok' | 'aborted'> {
|
|
8339
|
+
if (session.closed || session.released) return 'ok'
|
|
8340
|
+
if (!controller.hasSettled && session.promptInFlight === true) {
|
|
8341
|
+
await this.bridge(session.agentId).cancel(session)
|
|
8342
|
+
controller.abortActiveSegment()
|
|
8343
|
+
}
|
|
8344
|
+
return this.waitForSettleGate(session, signal)
|
|
8345
|
+
}
|
|
8346
|
+
|
|
8347
|
+
/** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
|
|
8348
|
+
private waitForSettleGate(session: LiveSession, signal?: AbortSignal): Promise<'ok' | 'aborted'> {
|
|
8349
|
+
const pending = session.settled
|
|
8350
|
+
if (pending === undefined) return Promise.resolve('ok')
|
|
8351
|
+
if (signal?.aborted) return Promise.resolve('aborted')
|
|
8352
|
+
return new Promise(resolve => {
|
|
8353
|
+
const done = (value: 'ok' | 'aborted'): void => {
|
|
8354
|
+
signal?.removeEventListener('abort', onAbort)
|
|
8355
|
+
resolve(value)
|
|
8356
|
+
}
|
|
8357
|
+
const onAbort = (): void => { done('aborted') }
|
|
8358
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
8359
|
+
void pending.then(() => { done('ok') })
|
|
8360
|
+
})
|
|
8361
|
+
}
|
|
8362
|
+
|
|
8312
8363
|
async *streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
8313
8364
|
// The plugin's own conversation↔agent mapping decides who serves the turn
|
|
8314
8365
|
// (see `bindChatAgent`): the driver may arrive here with the previous
|
|
@@ -8514,26 +8565,81 @@ export class BridgeManager {
|
|
|
8514
8565
|
|
|
8515
8566
|
// One prompt slot per session, and a turn the user aborted still occupies
|
|
8516
8567
|
// the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
|
|
8517
|
-
|
|
8568
|
+
let claim = await bridge.claimTurn(session)
|
|
8518
8569
|
if (!claim.ok) {
|
|
8519
|
-
//
|
|
8520
|
-
//
|
|
8521
|
-
//
|
|
8522
|
-
//
|
|
8523
|
-
//
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8570
|
+
// A STEER ("插话"): the host delivers the user's mid-turn message as the
|
|
8571
|
+
// NEXT step of the SAME host turn, so the conversation's own ACP prompt
|
|
8572
|
+
// is still on air. Refusing would fail the whole host turn. ACP forbids
|
|
8573
|
+
// injecting into a running prompt, so the steer's real semantic — abort
|
|
8574
|
+
// the current turn, then send this one — is spelled cancel + wait +
|
|
8575
|
+
// re-claim here.
|
|
8576
|
+
//
|
|
8577
|
+
// Only `busy` can be a steer: it means the AGENT still holds OUR prompt
|
|
8578
|
+
// (`promptInFlight`). `unsettled` means our side already released the
|
|
8579
|
+
// slot (the person pressed stop, or an earlier steer cancelled): the
|
|
8580
|
+
// agent has not acknowledged yet, and quietly queueing the next message
|
|
8581
|
+
// behind a turn the user stopped is not this path's business — that
|
|
8582
|
+
// stays the honest `turn_in_flight` refusal below.
|
|
8583
|
+
//
|
|
8584
|
+
// Only the conversation's OWN in-flight turn qualifies (its live stream
|
|
8585
|
+
// controller, on this very session). A `/acp` RPC turn has no chat
|
|
8586
|
+
// controller, and another conversation never reaches this session, so
|
|
8587
|
+
// both keep the plain refusal: nothing may be cancelled behind a
|
|
8588
|
+
// caller's back.
|
|
8589
|
+
const chatController = claim.reason === 'busy' && options.sessionId !== undefined
|
|
8590
|
+
? this.turnStreamFor(options.sessionId)
|
|
8591
|
+
: undefined
|
|
8592
|
+
if (options.sessionId !== undefined
|
|
8593
|
+
&& chatController !== undefined
|
|
8594
|
+
&& !chatController.stale
|
|
8595
|
+
&& chatController.session === session) {
|
|
8596
|
+
const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal)
|
|
8597
|
+
if (reclaimed === 'aborted') {
|
|
8598
|
+
// The person pressed stop while the previous turn was winding down:
|
|
8599
|
+
// the steer is theirs to re-send, nothing goes to the agent.
|
|
8600
|
+
yield {
|
|
8601
|
+
type: 'finish',
|
|
8602
|
+
reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
|
|
8603
|
+
}
|
|
8604
|
+
return
|
|
8605
|
+
}
|
|
8606
|
+
// A first turn that produced nothing rolled its provisional lock back
|
|
8607
|
+
// and CLOSED (and forgot) this session: the steer needs a fresh one.
|
|
8608
|
+
if (session.closed || session.released) {
|
|
8609
|
+
try {
|
|
8610
|
+
session = await this.getOrCreateDshSession(options.sessionId, agentId)
|
|
8611
|
+
} catch (err) {
|
|
8612
|
+
yield {
|
|
8613
|
+
type: 'finish',
|
|
8614
|
+
reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
|
|
8615
|
+
}
|
|
8616
|
+
return
|
|
8617
|
+
}
|
|
8618
|
+
}
|
|
8619
|
+
// Same bridge: the session may have been re-created, but the
|
|
8620
|
+
// conversation is still served by this agent's process.
|
|
8621
|
+
claim = await bridge.claimTurn(session)
|
|
8622
|
+
}
|
|
8623
|
+
if (!claim.ok) {
|
|
8624
|
+
// Never roll the provisional lock back here: a failed claim means
|
|
8625
|
+
// ANOTHER prompt holds the slot (or the agent has not settled it), and
|
|
8626
|
+
// that turn owns the conversation's lock. Closing the session would
|
|
8627
|
+
// kill a turn that is actively streaming — the refusal already tells
|
|
8628
|
+
// the user to retry.
|
|
8629
|
+
yield {
|
|
8630
|
+
type: 'finish',
|
|
8631
|
+
reason: {
|
|
8632
|
+
kind: 'error',
|
|
8633
|
+
failure: {
|
|
8634
|
+
message: claim.reason === 'busy'
|
|
8635
|
+
? 'acp: this session already has a prompt in flight'
|
|
8636
|
+
: 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
|
|
8637
|
+
code: 'turn_in_flight',
|
|
8638
|
+
},
|
|
8533
8639
|
},
|
|
8534
|
-
}
|
|
8640
|
+
}
|
|
8641
|
+
return
|
|
8535
8642
|
}
|
|
8536
|
-
return
|
|
8537
8643
|
}
|
|
8538
8644
|
|
|
8539
8645
|
// Same-agent model/effort switches ride the SAME ACP session (context
|
package/src/impl.ts
CHANGED
|
@@ -867,6 +867,48 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
|
|
|
867
867
|
|
|
868
868
|
// ---- Model catalog (pure parsing of ACP `configOptions`) ----
|
|
869
869
|
|
|
870
|
+
/**
|
|
871
|
+
* ACP categories are free-form UX hints, not identity. Qoder CN tags
|
|
872
|
+
* `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
|
|
873
|
+
* and nothing stops an agent from reusing a category for several options.
|
|
874
|
+
* Slots are therefore assigned by `configId` first and category second, and a
|
|
875
|
+
* same-category option NEVER replaces another option's slot: whatever loses
|
|
876
|
+
* stays reachable through `extras` instead of silently becoming the model list.
|
|
877
|
+
*/
|
|
878
|
+
const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i
|
|
879
|
+
|
|
880
|
+
function isModelSelector(configId: string, name: string): boolean {
|
|
881
|
+
return configId === 'model' || name.trim().toLowerCase() === 'model'
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
type CatalogSlot = 'model' | 'thought' | 'mode'
|
|
885
|
+
|
|
886
|
+
interface CatalogSlotCandidate {
|
|
887
|
+
slot: CatalogSlot
|
|
888
|
+
rank: number
|
|
889
|
+
option: AcpCatalogOption
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function catalogSlotFor(category: string | undefined, configId: string, name: string): Omit<CatalogSlotCandidate, 'option'> | undefined {
|
|
893
|
+
const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name)
|
|
894
|
+
if (category === 'model') {
|
|
895
|
+
// The option explicitly named `model` is the selector even when other
|
|
896
|
+
// model-scoped options are present.
|
|
897
|
+
if (isModelSelector(configId, name)) return { slot: 'model', rank: 3 }
|
|
898
|
+
// A model-scoped effort is mislabelled, not a model: route it to the
|
|
899
|
+
// thought chip when no real `thought_level` option is published.
|
|
900
|
+
if (effortish) return { slot: 'thought', rank: 1 }
|
|
901
|
+
// An unknown model-scoped select: a last-resort selector only.
|
|
902
|
+
return { slot: 'model', rank: 1 }
|
|
903
|
+
}
|
|
904
|
+
if (category === 'thought_level') return { slot: 'thought', rank: 3 }
|
|
905
|
+
if (category === 'mode') return { slot: 'mode', rank: 2 }
|
|
906
|
+
// `model_config` is where the spec puts model-dependent knobs (effort,
|
|
907
|
+
// context size); only the effort-shaped ones have a dedicated control.
|
|
908
|
+
if (category === 'model_config' && effortish) return { slot: 'thought', rank: 1 }
|
|
909
|
+
return undefined
|
|
910
|
+
}
|
|
911
|
+
|
|
870
912
|
/**
|
|
871
913
|
* Extract select options from a raw ACP `configOptions` payload.
|
|
872
914
|
*
|
|
@@ -880,24 +922,19 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
|
|
|
880
922
|
export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
881
923
|
const out: AcpAgentCatalog = {}
|
|
882
924
|
if (!Array.isArray(configOptions)) return out
|
|
883
|
-
const
|
|
925
|
+
const definitions: AcpCatalogOption[] = []
|
|
926
|
+
const candidates: CatalogSlotCandidate[] = []
|
|
884
927
|
for (const raw of configOptions) {
|
|
885
928
|
if (typeof raw !== 'object' || raw === null) continue
|
|
886
929
|
const row = raw as Record<string, unknown>
|
|
887
930
|
const category = typeof row.category === 'string' ? row.category : undefined
|
|
888
|
-
const slot = category === 'model'
|
|
889
|
-
? 'model'
|
|
890
|
-
: category === 'thought_level'
|
|
891
|
-
? 'thought'
|
|
892
|
-
: category === 'mode'
|
|
893
|
-
? 'mode'
|
|
894
|
-
: undefined
|
|
895
931
|
const configIdRaw = row.configId ?? row.id
|
|
896
932
|
if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
|
|
897
933
|
const configId = String(configIdRaw)
|
|
898
934
|
if (configId === '') continue
|
|
899
935
|
const choices: NonNullable<AcpAgentCatalog['model']>['choices'] = []
|
|
900
936
|
collectCatalogChoices(row.options, choices)
|
|
937
|
+
const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId
|
|
901
938
|
// A boolean toggle has no `options` at all; its current value is a JSON
|
|
902
939
|
// boolean. It is encoded as 'true' / 'false' so the catalog stays
|
|
903
940
|
// string-typed for every existing reader, and carries `kind` so the
|
|
@@ -906,7 +943,7 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
|
906
943
|
|| (typeof row.currentValue === 'boolean' && choices.length === 0)
|
|
907
944
|
const option: AcpCatalogOption = {
|
|
908
945
|
configId,
|
|
909
|
-
name
|
|
946
|
+
name,
|
|
910
947
|
...(booleanKind ? { kind: 'boolean' as const } : {}),
|
|
911
948
|
currentValue: booleanKind
|
|
912
949
|
? row.currentValue === true || row.currentValue === 'true'
|
|
@@ -915,21 +952,69 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
|
915
952
|
: typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
|
|
916
953
|
choices,
|
|
917
954
|
}
|
|
955
|
+
definitions.push(option)
|
|
918
956
|
// A toggle is never a model / thought / mode SELECT: those controls pick
|
|
919
957
|
// among choices. It belongs to the advanced base, where it is a checkbox.
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
958
|
+
if (booleanKind) continue
|
|
959
|
+
const slot = catalogSlotFor(category, configId, name)
|
|
960
|
+
if (slot !== undefined) candidates.push({ ...slot, option })
|
|
961
|
+
}
|
|
962
|
+
const winnerIds = new Set<string>()
|
|
963
|
+
for (const slot of ['model', 'thought', 'mode'] as const) {
|
|
964
|
+
let winner: CatalogSlotCandidate | undefined
|
|
965
|
+
for (const candidate of candidates) {
|
|
966
|
+
if (candidate.slot !== slot) continue
|
|
967
|
+
if (winner === undefined || candidate.rank > winner.rank) {
|
|
968
|
+
winner = candidate
|
|
969
|
+
} else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
|
|
970
|
+
// The same option redefined later in one answer: the last definition
|
|
971
|
+
// is the current one.
|
|
972
|
+
winner = candidate
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (winner === undefined) continue
|
|
976
|
+
out[slot] = winner.option
|
|
977
|
+
winnerIds.add(winner.option.configId)
|
|
978
|
+
}
|
|
979
|
+
// Choice-less options have nothing to pick; they would render an empty
|
|
980
|
+
// section in the advanced menu, so they stay out of the catalog entirely —
|
|
981
|
+
// except a boolean, whose whole point is that it has no choices. Anything
|
|
982
|
+
// that lost a slot race is still a real, settable option: keep it here.
|
|
983
|
+
const extras = new Map<string, AcpCatalogOption>()
|
|
984
|
+
for (const option of definitions) {
|
|
985
|
+
if (winnerIds.has(option.configId)) continue
|
|
986
|
+
if (option.choices.length > 0 || option.kind === 'boolean') extras.set(option.configId, option)
|
|
928
987
|
}
|
|
929
988
|
if (extras.size > 0) out.extras = [...extras.values()]
|
|
930
989
|
return out
|
|
931
990
|
}
|
|
932
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Whether a payload names ONE model selector without relying on category
|
|
994
|
+
* alone. When an agent publishes several `category: 'model'` options and none
|
|
995
|
+
* is explicitly the `model` id, the category is not trustworthy enough to
|
|
996
|
+
* reject the user's pick — callers must fail open instead of reporting
|
|
997
|
+
* `model_unavailable`.
|
|
998
|
+
*/
|
|
999
|
+
export function hasUnambiguousModelSelector(configOptions: unknown): boolean {
|
|
1000
|
+
if (!Array.isArray(configOptions)) return true
|
|
1001
|
+
let scoped = 0
|
|
1002
|
+
let explicit = false
|
|
1003
|
+
for (const raw of configOptions) {
|
|
1004
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
1005
|
+
const row = raw as Record<string, unknown>
|
|
1006
|
+
if (row.category !== 'model') continue
|
|
1007
|
+
const configIdRaw = row.configId ?? row.id
|
|
1008
|
+
if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
|
|
1009
|
+
const configId = String(configIdRaw)
|
|
1010
|
+
if (configId === '') continue
|
|
1011
|
+
scoped += 1
|
|
1012
|
+
const name = typeof row.name === 'string' ? row.name : ''
|
|
1013
|
+
if (isModelSelector(configId, name)) explicit = true
|
|
1014
|
+
}
|
|
1015
|
+
return explicit || scoped <= 1
|
|
1016
|
+
}
|
|
1017
|
+
|
|
933
1018
|
/**
|
|
934
1019
|
* Fold one per-session option answer's SHAPE into an agent-level catalog.
|
|
935
1020
|
*
|