dsh-bailinghub 0.5.0 → 0.7.0
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 +79 -0
- package/PRIVACY.md +16 -5
- package/README.md +21 -3
- package/SECURITY.md +10 -2
- package/docs/AGENT_CLIENT_CONTRACT.md +27 -7
- package/docs/CAPABILITY_FEEDBACK.md +199 -0
- package/docs/COMPATIBILITY.md +19 -6
- package/docs/CROSS_SYSTEM_CONVERSATIONS.md +4 -2
- package/docs/GENERATED_ARTIFACTS.md +77 -0
- package/docs/GETTING_STARTED.md +5 -5
- package/docs/GETTING_STARTED.zh-CN.md +5 -5
- package/docs/INVOCATION_RECOVERY.md +182 -0
- package/docs/MIGRATION_VNEXT.md +8 -0
- package/docs/README.zh-CN.md +37 -3
- package/docs/RELEASE_NOTES_v0.6.0.en.md +39 -0
- package/docs/RELEASE_NOTES_v0.6.0.md +39 -0
- package/docs/RELEASE_NOTES_v0.7.0.en.md +15 -0
- package/docs/RELEASE_NOTES_v0.7.0.md +15 -0
- package/docs/SESSION_TOOL_REUSE.md +213 -0
- package/docs/TASK_CONTROL.md +183 -0
- package/docs/UPGRADE_v0.6.0.en.md +71 -0
- package/docs/UPGRADE_v0.6.0.md +73 -0
- package/docs/UPGRADE_v0.7.0.en.md +15 -0
- package/docs/UPGRADE_v0.7.0.md +15 -0
- package/docs/USAGE_SERVICE.md +96 -0
- package/lib/artifact-store.js +56 -0
- package/lib/artifacts.js +132 -0
- package/lib/capability-feedback.js +138 -0
- package/lib/conversation-outbox.js +24 -0
- package/lib/index.js +16 -1
- package/lib/invocation-journal.js +228 -0
- package/lib/invocation-store.js +275 -0
- package/lib/model-gateway-transport.js +246 -0
- package/lib/runtime.js +951 -146
- package/lib/session-scope.js +47 -5
- package/lib/session-task-store.js +275 -0
- package/lib/session-task.js +237 -0
- package/lib/session-tool-cache.js +168 -0
- package/lib/session-tools.js +240 -0
- package/lib/session-usage-store.js +280 -0
- package/lib/tool-catalog.js +38 -0
- package/lib/transport.js +19 -0
- package/lib/usage-llm-stream.js +157 -0
- package/lib/usage-model-transport.js +206 -0
- package/lib/usage-provider-stream.js +82 -0
- package/package.json +18 -3
package/lib/runtime.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
+
import { listArtifacts, deliverArtifact, artifactFailure } from './artifacts.js'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
2
3
|
import {
|
|
3
4
|
authorizationEnvelope, authorizedResult, declarationKey,
|
|
4
5
|
sharedDeclarations, unwrapAuthorization,
|
|
5
6
|
} from './authorizations.js'
|
|
6
7
|
import { SessionScopeCoordinator, scopeError, isCrossSystemScope, memberBinding, archiveMembers } from './session-scope.js'
|
|
8
|
+
import { InvocationJournal, scopeFingerprint, metadataHash, recoveryError } from './invocation-journal.js'
|
|
9
|
+
import { SessionTaskCoordinator, taskError } from './session-task.js'
|
|
10
|
+
import { createFileSessionTaskStore } from './session-task-store.js'
|
|
11
|
+
import { createFileInvocationStore } from './invocation-store.js'
|
|
7
12
|
import { ConversationOutbox } from './conversation-outbox.js'
|
|
8
13
|
import { isSystemInfoAuthorizationFailure, projectSystemInfo, SYSTEM_DESCRIPTION_GUIDANCE, targetDirectoryEntry, unknownSystemInfo } from './system-info.js'
|
|
9
14
|
import { projectSubjectDisplay, subjectDisplayLabel, SUBJECT_DISPLAY_GUIDANCE } from './subject-display.js'
|
|
15
|
+
import { describeFailure, isInvocationNotFound, normalizeDiscovery, DISCOVERY_GUIDANCE } from './capability-feedback.js'
|
|
16
|
+
import { VISIBLE_TOOL_LIMIT, RETAINED_TOOL_LIMIT, mergeDiscoveredTools, touchCatalogTool } from './tool-catalog.js'
|
|
17
|
+
import { SessionToolCache } from './session-tool-cache.js'
|
|
18
|
+
import { SESSION_TOOL_GUIDANCE, captureSessionTools, sessionToolView, replaceSessionTools } from './session-tools.js'
|
|
10
19
|
|
|
11
|
-
const MAX_ACTIVE_TOOLS =
|
|
20
|
+
const MAX_ACTIVE_TOOLS = VISIBLE_TOOL_LIMIT
|
|
12
21
|
const TOOL_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
|
|
13
22
|
const INVOCATION_ID = /^[0-9a-f]{64}$/
|
|
14
23
|
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
|
|
@@ -24,8 +33,11 @@ const PUBLIC_USAGE_ALIASES = {
|
|
|
24
33
|
}
|
|
25
34
|
const RESERVED_TOOLS = new Set([
|
|
26
35
|
'run_code',
|
|
36
|
+
'list_generated_artifacts',
|
|
37
|
+
'upload_generated_artifacts',
|
|
27
38
|
'search_business_capabilities',
|
|
28
39
|
'resume_governed_tool_invocation',
|
|
40
|
+
'inspect_governed_tool_invocation',
|
|
29
41
|
])
|
|
30
42
|
const INVOCATION_STATES = new Set([
|
|
31
43
|
'executed',
|
|
@@ -190,13 +202,18 @@ function redactText(value) {
|
|
|
190
202
|
return text
|
|
191
203
|
}
|
|
192
204
|
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
function feedbackOperation(operation) {
|
|
206
|
+
if (operation === 'searchCapabilities') return 'search'
|
|
207
|
+
if (['search', 'invoke', 'resume', 'inspect'].includes(operation)) return operation
|
|
208
|
+
return ['status', 'login', 'authorization', 'authorizationTools'].includes(operation) ? 'authorize' : 'tool_dispatch'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function publicFailure(operation, error, context = {}) {
|
|
212
|
+
const failure = new Error('BailingHub operation failed')
|
|
198
213
|
failure.name = 'BailingHubAgentClientError'
|
|
199
214
|
failure.code = 'BAILINGHUB_AGENT_CLIENT_UNAVAILABLE'
|
|
215
|
+
failure.feedback = describeFailure(error, { operation: feedbackOperation(operation), ...context })
|
|
216
|
+
failure.message = failure.feedback.message
|
|
200
217
|
return failure
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -208,6 +225,7 @@ function acceptedUnknownFailure(operation, invocationId) {
|
|
|
208
225
|
failure.code = 'BAILINGHUB_ACCEPTED_UNKNOWN'
|
|
209
226
|
failure.disposition = 'accepted_unknown'
|
|
210
227
|
failure.invocationId = invocationId
|
|
228
|
+
failure.feedback = describeFailure(failure, { operation: 'resume', invocationId })
|
|
211
229
|
return failure
|
|
212
230
|
}
|
|
213
231
|
|
|
@@ -267,7 +285,7 @@ function normalizeRecoveryOptions(options = {}) {
|
|
|
267
285
|
}
|
|
268
286
|
}
|
|
269
287
|
|
|
270
|
-
function invocationResult(value, invocationId) {
|
|
288
|
+
function invocationResult(value, invocationId, operation = 'invoke') {
|
|
271
289
|
let normalized
|
|
272
290
|
try {
|
|
273
291
|
normalized = normalizeToolValue(value)
|
|
@@ -286,6 +304,14 @@ function invocationResult(value, invocationId) {
|
|
|
286
304
|
) {
|
|
287
305
|
return undefined
|
|
288
306
|
}
|
|
307
|
+
if (normalized.state === 'reconciliation_required') {
|
|
308
|
+
normalized.feedback = describeFailure({ feedback: normalized.feedback ?? {
|
|
309
|
+
schema: 'bailing.agent-feedback.v1', category: 'invocation_outcome_unknown', code: 'reconciliation_required',
|
|
310
|
+
origin: 'core', dispatch: 'unknown', next_action: 'inspect_original', invocation_id: invocationId,
|
|
311
|
+
} }, { operation, invocationId })
|
|
312
|
+
} else if (normalized.feedback) {
|
|
313
|
+
normalized.feedback = describeFailure({ feedback: normalized.feedback }, { operation, invocationId })
|
|
314
|
+
}
|
|
289
315
|
return normalized
|
|
290
316
|
}
|
|
291
317
|
|
|
@@ -299,16 +325,17 @@ function invocationIsTerminal(result) {
|
|
|
299
325
|
return result !== undefined && !invocationNeedsResume(result)
|
|
300
326
|
}
|
|
301
327
|
|
|
302
|
-
function timedOutInvocationResult(result, invocationId, attempts) {
|
|
328
|
+
function timedOutInvocationResult(result, invocationId, attempts, managed = false) {
|
|
303
329
|
return {
|
|
304
330
|
...cloneJson(result),
|
|
305
331
|
agent_client_wait: {
|
|
306
332
|
state: 'timed_out',
|
|
307
333
|
invocation_id: invocationId,
|
|
308
|
-
resume_required:
|
|
334
|
+
resume_required: !managed,
|
|
335
|
+
...(managed ? { inspect_tool: 'inspect_governed_tool_invocation', continuation_requires_explicit_intent: true } : {}),
|
|
309
336
|
resume_tool: 'resume_governed_tool_invocation',
|
|
310
337
|
resume_attempts: attempts,
|
|
311
|
-
next_action: 'Resume this exact invocation_id; never call the original business tool again.',
|
|
338
|
+
next_action: managed ? 'Inspect this exact invocation_id to observe its result. Continue it only with explicit intent through resume_governed_tool_invocation; never create a replacement.' : 'Resume this exact invocation_id; never call the original business tool again.',
|
|
312
339
|
},
|
|
313
340
|
}
|
|
314
341
|
}
|
|
@@ -520,7 +547,7 @@ export function normalizeStartTurnResponse(value) {
|
|
|
520
547
|
}
|
|
521
548
|
const schema = value.schema ?? value.schema_version
|
|
522
549
|
if (schema !== 'bailing.agent-turn-context.v1') {
|
|
523
|
-
throw new TypeError('startTurn response has an unsupported schema')
|
|
550
|
+
throw Object.assign(new TypeError('startTurn response has an unsupported schema'), { publicCode: 'agent_schema_unsupported' })
|
|
524
551
|
}
|
|
525
552
|
if (typeof value.run_id !== 'string' || !UUID.test(value.run_id.trim())) {
|
|
526
553
|
throw new TypeError('startTurn response is missing run_id')
|
|
@@ -555,7 +582,7 @@ export function normalizeCapabilitySearchResponse(value) {
|
|
|
555
582
|
}
|
|
556
583
|
const schema = value.schema ?? value.schema_version
|
|
557
584
|
if (schema !== 'bailing.agent-capability-search.v1') {
|
|
558
|
-
throw new TypeError('searchCapabilities response has an unsupported schema')
|
|
585
|
+
throw Object.assign(new TypeError('searchCapabilities response has an unsupported schema'), { publicCode: 'agent_schema_unsupported' })
|
|
559
586
|
}
|
|
560
587
|
if (!Array.isArray(value.tools) || value.tools.length > MAX_ACTIVE_TOOLS) {
|
|
561
588
|
throw new TypeError('searchCapabilities response has an invalid tools list')
|
|
@@ -568,6 +595,7 @@ export function normalizeCapabilitySearchResponse(value) {
|
|
|
568
595
|
schema,
|
|
569
596
|
capabilityRevision: normalizeRevision(value.capability_revision, 'capability_revision'),
|
|
570
597
|
tools,
|
|
598
|
+
discovery: normalizeDiscovery(value.discovery, tools.length),
|
|
571
599
|
}
|
|
572
600
|
}
|
|
573
601
|
|
|
@@ -593,6 +621,7 @@ function profileSection(state, run) {
|
|
|
593
621
|
`Capability revision: ${run.capabilityRevision}`,
|
|
594
622
|
SYSTEM_DESCRIPTION_GUIDANCE,
|
|
595
623
|
SUBJECT_DISPLAY_GUIDANCE,
|
|
624
|
+
DISCOVERY_GUIDANCE,
|
|
596
625
|
`Authorization directory: ${safeJson([targetDirectoryEntry(state, run)])}`,
|
|
597
626
|
'BailingHub Core is the authority for identity, authorization, capability-candidate trimming, approval, invocation, and audit. The local Agent chooses among the active candidates and performs the local reasoning and orchestration. Use only the active BailingHub tools exposed in this turn.',
|
|
598
627
|
`Workspace instructions:\n${instructions}`,
|
|
@@ -754,7 +783,12 @@ function createState(agent, workspace, connectionName) {
|
|
|
754
783
|
systemInfoLoads: new Map(),
|
|
755
784
|
activeDefinitions: new Map(),
|
|
756
785
|
activeDisposers: new Map(),
|
|
786
|
+
definitionSlots: new Map(),
|
|
787
|
+
visibleToolNames: [],
|
|
788
|
+
toolUsage: new Map(),
|
|
789
|
+
toolUseSequence: 0,
|
|
757
790
|
knownToolNames: new Set(),
|
|
791
|
+
toolsetGeneration: 0,
|
|
758
792
|
status: { state: 'ready' },
|
|
759
793
|
disposed: false,
|
|
760
794
|
}
|
|
@@ -769,13 +803,24 @@ function conversationStarted(session) {
|
|
|
769
803
|
}
|
|
770
804
|
|
|
771
805
|
export class BailingHubAgentClientRuntime {
|
|
772
|
-
constructor(ctx, config, getTransport, recoveryOptions = {}, { scopeStore, archiveStore } = {}) {
|
|
806
|
+
constructor(ctx, config, getTransport, recoveryOptions = {}, { scopeStore, archiveStore, artifactSource, artifactStore, invocationStore, taskStore, toolLifecycle = 'active_turn' } = {}) {
|
|
807
|
+
if (!['active_turn', 'session'].includes(toolLifecycle)) throw new TypeError('Unsupported toolLifecycle')
|
|
808
|
+
this.sessionToolReuse = toolLifecycle === 'session'
|
|
809
|
+
// Custom hosts own their persistence namespace; never silently put their
|
|
810
|
+
// recovery records in an unrelated default directory.
|
|
811
|
+
this.invocationJournal = new InvocationJournal(invocationStore === undefined
|
|
812
|
+
? scopeStore === undefined ? createFileInvocationStore() : null : invocationStore)
|
|
813
|
+
this.artifactSource = artifactSource
|
|
814
|
+
this.artifactStore = artifactStore
|
|
773
815
|
this.ctx = ctx
|
|
774
816
|
this.config = normalizeConfig(config)
|
|
775
817
|
this.getTransport = getTransport
|
|
776
818
|
this.sessionScopes = new SessionScopeCoordinator({
|
|
777
819
|
store: scopeStore, getTransport, normalizeConfig, sanitize: sanitizePublicValue,
|
|
778
820
|
})
|
|
821
|
+
this.sessionTasks = new SessionTaskCoordinator({ store: taskStore === undefined
|
|
822
|
+
? scopeStore === undefined ? createFileSessionTaskStore() : null : taskStore,
|
|
823
|
+
scopes: this.sessionScopes, getTransport, conversationId: id => messageAlias('conversation', id) })
|
|
779
824
|
this.conversationOutbox = new ConversationOutbox({ store: archiveStore })
|
|
780
825
|
this.archiveAvailability = new Map()
|
|
781
826
|
this.recovery = normalizeRecoveryOptions(recoveryOptions)
|
|
@@ -792,9 +837,15 @@ export class BailingHubAgentClientRuntime {
|
|
|
792
837
|
this.statesBySessionId = new Map()
|
|
793
838
|
this.observedSessions = new Map()
|
|
794
839
|
this.observedConversationStarts = new Map()
|
|
840
|
+
this.toolFailures = new WeakMap()
|
|
841
|
+
this.invocationOperations = new Map()
|
|
842
|
+
this.hostActionControllers = new Map()
|
|
843
|
+
this.hostReceiptBindings = new Set()
|
|
844
|
+
this.disposed = false
|
|
795
845
|
}
|
|
796
846
|
|
|
797
847
|
install() {
|
|
848
|
+
this.ctx.on('tools/execute', (exec, next) => this.onToolExecute(exec, next))
|
|
798
849
|
this.ctx.on('session/created', (session) => this.observeSession(session))
|
|
799
850
|
this.ctx.on('agent/inbox/claimed', (payload) => this.onInboxClaimed(payload))
|
|
800
851
|
this.ctx.on('system-prompt/assemble', (assembly, context, next) =>
|
|
@@ -806,7 +857,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
806
857
|
const registerCommand = () => this.ctx.commands.register({
|
|
807
858
|
name: 'bailinghub',
|
|
808
859
|
description: 'authorize, select conversation scope, diagnose, or resync BailingHub Agent Client connections',
|
|
809
|
-
input: { hint: 'scope [set <connection-key>...|none]|archive [status|sync]|doctor|connections list|add|use|remove|login|status|logout|workspaces|use <workspace>|sync' },
|
|
860
|
+
input: { hint: 'scope [set <connection-key>...|none]|archive [status|sync]|invocations [status|restore]|doctor|connections list|add|use|remove|login|status|logout|workspaces|use <workspace>|sync' },
|
|
810
861
|
handler: (invocation) => this.handleCommand(invocation),
|
|
811
862
|
})
|
|
812
863
|
if (typeof this.ctx.effect === 'function') this.ctx.effect(registerCommand)
|
|
@@ -828,6 +879,15 @@ export class BailingHubAgentClientRuntime {
|
|
|
828
879
|
const result = operation === 'sync' ? await this.syncSessionArchive(String(sessionId)) : await this.getSessionArchiveStatus(String(sessionId))
|
|
829
880
|
return { kind: result.state === 'synced' || result.state === 'inactive' ? 'success' : 'error', text: commandText(result) }
|
|
830
881
|
}
|
|
882
|
+
if (action === 'invocations') {
|
|
883
|
+
const sessionId = invocation.agent?.session?.id
|
|
884
|
+
if (!sessionId) throw scopeError('INVALID_SESSION_ID')
|
|
885
|
+
this.observeSession(invocation.agent.session)
|
|
886
|
+
const [operation = 'status', ...extra] = rest
|
|
887
|
+
if (extra.length || !['status', 'restore'].includes(operation)) return { kind: 'error', text: 'Usage: /bailinghub invocations [status|restore]' }
|
|
888
|
+
const result = operation === 'restore' ? await this.restoreSessionInvocations(String(sessionId)) : await this.getSessionInvocationStatus(String(sessionId))
|
|
889
|
+
return { kind: ['ready', 'inactive'].includes(result.state) ? 'success' : 'error', text: commandText(result) }
|
|
890
|
+
}
|
|
831
891
|
if (action === 'scope') {
|
|
832
892
|
const sessionId = invocation.agent?.session?.id
|
|
833
893
|
if (!sessionId) throw scopeError('INVALID_SESSION_ID')
|
|
@@ -903,7 +963,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
903
963
|
default:
|
|
904
964
|
return {
|
|
905
965
|
kind: 'error',
|
|
906
|
-
text: 'Usage: /bailinghub scope [set <connection-key>...|none]|archive [status|sync]|doctor|connections list|add|use|remove|login|status|logout|workspaces|use <workspace>|sync',
|
|
966
|
+
text: 'Usage: /bailinghub scope [set <connection-key>...|none]|archive [status|sync]|invocations [status|restore]|doctor|connections list|add|use|remove|login|status|logout|workspaces|use <workspace>|sync',
|
|
907
967
|
}
|
|
908
968
|
}
|
|
909
969
|
} catch (error) {
|
|
@@ -946,6 +1006,93 @@ export class BailingHubAgentClientRuntime {
|
|
|
946
1006
|
return this.sessionScopes.set(sessionId, request)
|
|
947
1007
|
}
|
|
948
1008
|
|
|
1009
|
+
async setSessionTaskBinding(session, request) {
|
|
1010
|
+
const id = typeof session === 'string' ? session : String(session.id)
|
|
1011
|
+
if (typeof session !== 'string') this.observeSession(session)
|
|
1012
|
+
const state = this.statesBySessionId.get(id)
|
|
1013
|
+
if (!this.sessionTasks.entry(id).record && state && [...state.runs.values()].some(run => run.runId || run.targets?.some(target => target.run?.runId))) throw taskError('TASK_BINDING_CONFLICT')
|
|
1014
|
+
return this.sessionTasks.set(id, request)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Synchronous snapshot; queued local writes can still be pending. */
|
|
1018
|
+
getSessionLocalPrimaryStatus(session) {
|
|
1019
|
+
if (!session || typeof session.id !== 'string' || !session.id.trim() || session.id.length > 512
|
|
1020
|
+
|| /[\u0000-\u001f\u007f]/.test(session.id) || !Array.isArray(session.events)) {
|
|
1021
|
+
throw new TypeError('A real Session with its current events is required')
|
|
1022
|
+
}
|
|
1023
|
+
const view = this.sessionTaskView(session.id, session)
|
|
1024
|
+
return { schema: 'bailing.agent-session-local-primary.v1',
|
|
1025
|
+
state: ['storage_error', 'recovery_gap'].includes(view.state) ? view.state : 'ready',
|
|
1026
|
+
local: { ...view.local }, snapshot_is_dispatch_permission: false }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Settle already queued local persistence before checking the primary error. */
|
|
1030
|
+
async waitForSessionLocalPrimaryStatus(session) {
|
|
1031
|
+
this.getSessionLocalPrimaryStatus(session) // Validate before touching queues.
|
|
1032
|
+
await Promise.all([
|
|
1033
|
+
this.conversationOutbox.drain(session.id),
|
|
1034
|
+
this.invocationJournal.drain(session.id),
|
|
1035
|
+
])
|
|
1036
|
+
// Task queues also contain remote validation; never wait for them here.
|
|
1037
|
+
// Failed saves retain their original pending records and error status.
|
|
1038
|
+
return this.getSessionLocalPrimaryStatus(session)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async getSessionTaskState(session) {
|
|
1042
|
+
const id = typeof session === 'string' ? session : String(session.id)
|
|
1043
|
+
await this.sessionTasks.refresh(id)
|
|
1044
|
+
return this.sessionTaskView(id, typeof session === 'string' ? undefined : session)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async getSessionTaskCoordinates(session) {
|
|
1048
|
+
const schema = 'bailing.agent-session-task-coordinates.v1'
|
|
1049
|
+
const failure = error => {
|
|
1050
|
+
const feedback = describeFailure(error, { operation: 'task_coordinates', origin: 'dsh' })
|
|
1051
|
+
const state = feedback.code === 'SESSION_RECOVERY_GAP' ? 'recovery_gap'
|
|
1052
|
+
: feedback.code === 'AUTHORIZATION_UNVERIFIED' ? 'unavailable'
|
|
1053
|
+
: { storage_error: 'storage_error', unsupported: 'unsupported', transport_unavailable: 'unavailable' }[feedback.category] ?? 'blocked'
|
|
1054
|
+
return { state, availability: state === 'unsupported' ? 'unsupported' : 'unavailable', reason: feedback.code, feedback }
|
|
1055
|
+
}
|
|
1056
|
+
let id, result
|
|
1057
|
+
try {
|
|
1058
|
+
id = typeof session === 'string' ? session : session?.id
|
|
1059
|
+
this.sessionScopes.entry(id) // Validate before observing or probing anything.
|
|
1060
|
+
if (typeof session !== 'string') this.observeSession(session)
|
|
1061
|
+
const localFailure = () => {
|
|
1062
|
+
const local = this.sessionTaskView(id, typeof session === 'string' ? undefined : session)
|
|
1063
|
+
return local.state === 'storage_error' ? local.reason?.startsWith('TASK_STORE_') ? local.reason : 'SESSION_LOCAL_STORAGE_ERROR'
|
|
1064
|
+
: local.state === 'recovery_gap' ? 'SESSION_RECOVERY_GAP' : undefined
|
|
1065
|
+
}
|
|
1066
|
+
const before = localFailure()
|
|
1067
|
+
if (before) result = failure({ code: before })
|
|
1068
|
+
else {
|
|
1069
|
+
try { result = await this.sessionTasks.coordinates(id) } catch (error) { result = failure(error) }
|
|
1070
|
+
const after = localFailure()
|
|
1071
|
+
if (after && (after !== 'SESSION_RECOVERY_GAP' || result.state !== 'storage_error')) result = failure({ code: after })
|
|
1072
|
+
}
|
|
1073
|
+
} catch (error) { result = failure(error) }
|
|
1074
|
+
return { schema, ...result, snapshot_is_dispatch_permission: false }
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
sessionTaskView(id, session) {
|
|
1078
|
+
const task = this.sessionTasks.view(id)
|
|
1079
|
+
const sessionObject = session ?? this.statesBySessionId.get(id)?.agent.session ?? this.observedSessions.get(id)
|
|
1080
|
+
const journalFailure = this.invocationJournal.failures.get(id)
|
|
1081
|
+
const unsavedInvocations = this.invocationJournal.pending.get(id)?.size ?? 0
|
|
1082
|
+
const archive = this.archiveStatusWithCoverage(id, this.conversationOutbox.status(id))
|
|
1083
|
+
const unsavedEvents = Math.max(archive.unsavedEvents ?? 0, sessionObject?.unsavedEvents?.length ?? 0)
|
|
1084
|
+
const storage = task.state === 'storage_error' || unsavedEvents || unsavedInvocations || journalFailure?.startsWith('invocation_store_') || (archive.state === 'storage_error' || archive.syncState === 'storage_error')
|
|
1085
|
+
return { ...task, ...(storage ? { state: 'storage_error' } : archive.state === 'recovery_gap' ? { state: 'recovery_gap' } : {}),
|
|
1086
|
+
local: { task: task.state, invocation: journalFailure ?? (unsavedInvocations ? 'storage_error' : 'ready'),
|
|
1087
|
+
archive: archive.state, unsavedEvents, unsavedInvocations } }
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async restoreSessionTaskBinding(session) {
|
|
1091
|
+
const id = typeof session === 'string' ? session : String(session.id)
|
|
1092
|
+
await this.sessionTasks.refresh(id, true)
|
|
1093
|
+
return this.sessionTaskView(id, typeof session === 'string' ? undefined : session)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
949
1096
|
observeSession(session) {
|
|
950
1097
|
if (!session?.id) return
|
|
951
1098
|
const id = String(session.id)
|
|
@@ -979,6 +1126,208 @@ export class BailingHubAgentClientRuntime {
|
|
|
979
1126
|
return this.sessionScopes.restore(sessionId, this.inspectSessionLifecycle(sessionId))
|
|
980
1127
|
}
|
|
981
1128
|
|
|
1129
|
+
getSessionInvocationStatus(sessionId) {
|
|
1130
|
+
return this.sessionInvocationStatus(sessionId, false)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
restoreSessionInvocations(sessionId) {
|
|
1134
|
+
return this.sessionInvocationStatus(sessionId, true)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
inspectSessionInvocation(session, invocationId, options = {}) {
|
|
1138
|
+
return this.sessionInvocationAction('inspect', session, invocationId, options)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
resumeSessionInvocation(session, invocationId, options = {}) {
|
|
1142
|
+
return this.sessionInvocationAction('resume', session, invocationId, options)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
localInvocationActionFailure(id, session) {
|
|
1146
|
+
const view = this.sessionTaskView(id, session)
|
|
1147
|
+
// An unloaded outbox is not yet evidence of a gap. The action restores the
|
|
1148
|
+
// original local metadata before testing coverage; no text is reconstructed.
|
|
1149
|
+
const outbox = this.conversationOutbox.entries.get(id)
|
|
1150
|
+
if (view.state === 'recovery_gap' && !outbox?.record && outbox?.coverageLoaded !== true) return undefined
|
|
1151
|
+
const code = view.state === 'storage_error'
|
|
1152
|
+
? view.reason?.startsWith('TASK_STORE_') ? view.reason : 'SESSION_LOCAL_STORAGE_ERROR'
|
|
1153
|
+
: view.state === 'recovery_gap' ? 'SESSION_RECOVERY_GAP' : undefined
|
|
1154
|
+
return code ? { feedback: describeFailure({ code }, { operation: 'task_coordinates', origin: 'dsh' }) } : undefined
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async sessionInvocationAction(operation, session, invocationId, options) {
|
|
1158
|
+
const schema = 'bailing.agent-session-invocation-action.v1'
|
|
1159
|
+
let id, signal, resumeDispatched = false
|
|
1160
|
+
try {
|
|
1161
|
+
if (!session || typeof session !== 'object' || !Array.isArray(session.events) ||
|
|
1162
|
+
!INVOCATION_ID.test(invocationId ?? '') || !options || Object.keys(options).some(key => key !== 'signal') ||
|
|
1163
|
+
options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
|
|
1164
|
+
throw Object.assign(new TypeError('A real Session and its original invocation id are required'), { publicCode: 'agent_invalid_request' })
|
|
1165
|
+
}
|
|
1166
|
+
id = session.id
|
|
1167
|
+
this.sessionScopes.entry(id)
|
|
1168
|
+
this.observeSession(session)
|
|
1169
|
+
if (!this.hostActionControllers.has(id)) this.hostActionControllers.set(id, new AbortController())
|
|
1170
|
+
signal = options.signal ? AbortSignal.any([options.signal, this.hostActionControllers.get(id).signal]) : this.hostActionControllers.get(id).signal
|
|
1171
|
+
const active = () => { if (this.disposed || signal.aborted) throw abortError() }
|
|
1172
|
+
const local = () => { const failure = this.localInvocationActionFailure(id, session); if (failure) throw failure }
|
|
1173
|
+
return await this.serializeInvocation(id, invocationId, async () => {
|
|
1174
|
+
local(); active()
|
|
1175
|
+
const selection = await this.sessionScopes.verifiedSelection(id)
|
|
1176
|
+
const scope = selection.record
|
|
1177
|
+
if (!scope?.locked || !scope.authorizations?.length) throw recoveryError('invocation_binding_unavailable', invocationId)
|
|
1178
|
+
await this.conversationOutbox.restoreExisting(id, { clientConversationId: messageAlias('conversation', id),
|
|
1179
|
+
binding: scope.binding, members: archiveMembers(scope) })
|
|
1180
|
+
local(); active(); await selection.check()
|
|
1181
|
+
const fingerprint = scopeFingerprint(scope)
|
|
1182
|
+
const status = await this.invocationJournal.status(id, fingerprint)
|
|
1183
|
+
if (status.state !== 'ready') throw recoveryError(status.reason ?? 'invocation_store_unavailable', invocationId)
|
|
1184
|
+
const original = await this.invocationJournal.find(id, invocationId, fingerprint)
|
|
1185
|
+
const member = scope.authorizations.find(value => value.connectionKey === original.target.connectionKey)
|
|
1186
|
+
const ref = this.sessionScopes.view(this.sessionScopes.entry(id)).authorizations.find(value => value.connectionKey === member?.connectionKey)?.authorizationRef
|
|
1187
|
+
const expected = member && { ...memberBinding(scope, member), connectionKey: member.connectionKey, agentSessionId: member.sessionId, authorizationRef: ref }
|
|
1188
|
+
if (!member || metadataHash(expected) !== metadataHash(original.target) || original.scopeRevision !== scope.revision) throw recoveryError('invocation_binding_conflict', invocationId)
|
|
1189
|
+
const record = { invocationId, journalEntry: original, journalSessionId: id, resumeNotBefore: original.retryAt }
|
|
1190
|
+
const transport = await this.getTransport()
|
|
1191
|
+
if (typeof transport.inspectInvocation !== 'function') throw taskError('TASK_UNSUPPORTED')
|
|
1192
|
+
const connection = { connectionKey: member.connectionKey, workspace: member.workspace, signal,
|
|
1193
|
+
expectedBinding: { ...memberBinding(scope, member), sessionId: member.sessionId } }
|
|
1194
|
+
// This action never borrows an active/ended turn or creates a run.
|
|
1195
|
+
const guard = async (dispatch = false) => {
|
|
1196
|
+
local(); active(); await selection.check()
|
|
1197
|
+
const task = await this.sessionTasks.binding(id, { dispatch })
|
|
1198
|
+
if (metadataHash(task ?? null) !== metadataHash(original.taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
1199
|
+
const current = await this.invocationJournal.find(id, invocationId, fingerprint)
|
|
1200
|
+
if (metadataHash(current) !== metadataHash(record.journalEntry)) throw recoveryError('invocation_store_conflict', invocationId)
|
|
1201
|
+
await selection.check(); local(); active()
|
|
1202
|
+
}
|
|
1203
|
+
const inspect = async () => {
|
|
1204
|
+
await guard()
|
|
1205
|
+
const receipt = this.checkedInvocationReceipt(await transport.inspectInvocation(invocationId, connection), original)
|
|
1206
|
+
await guard()
|
|
1207
|
+
if (receipt.result) await this.rememberInvocationResult(record, receipt.result)
|
|
1208
|
+
await guard()
|
|
1209
|
+
this.hostReceiptBindings.add(`${id}:${invocationId}`)
|
|
1210
|
+
return receipt
|
|
1211
|
+
}
|
|
1212
|
+
let receipt = await inspect()
|
|
1213
|
+
let result
|
|
1214
|
+
if (operation === 'resume') {
|
|
1215
|
+
await guard(true)
|
|
1216
|
+
const mayContinue = () => receipt.dispatch_state === 'not_dispatched' && receipt.journal_state === 'absent' && invocationNeedsResume(receipt.result)
|
|
1217
|
+
const delay = Math.max(0, record.resumeNotBefore - this.recovery.now())
|
|
1218
|
+
if (mayContinue() && delay >= this.recovery.maxWaitMilliseconds) return { schema, operation, invocation_id: invocationId,
|
|
1219
|
+
state: 'ready', resume_dispatched: false, receipt, retry_after_ms: delay, next_action: 'inspect_original' }
|
|
1220
|
+
if (mayContinue() && delay) {
|
|
1221
|
+
await this.recovery.sleep(delay, signal)
|
|
1222
|
+
receipt = await inspect()
|
|
1223
|
+
await guard(true)
|
|
1224
|
+
}
|
|
1225
|
+
if (mayContinue()) {
|
|
1226
|
+
await guard(true)
|
|
1227
|
+
selection.assertCurrent(); local(); active()
|
|
1228
|
+
resumeDispatched = true
|
|
1229
|
+
result = invocationResult(await transport.resume(invocationId, {}, connection), invocationId, 'resume')
|
|
1230
|
+
if (!result || result.route !== original.target.workspace || result.tool !== original.tool) throw recoveryError('invocation_binding_conflict', invocationId, 'unknown')
|
|
1231
|
+
// Even a late original response may update its original journal;
|
|
1232
|
+
// cancellation never re-enables tools or returns an actionable success.
|
|
1233
|
+
await this.rememberInvocationResult(record, result, { freshDispatch: true })
|
|
1234
|
+
await guard()
|
|
1235
|
+
receipt = await inspect()
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
await guard()
|
|
1239
|
+
return { schema, operation, invocation_id: invocationId, state: 'ready', resume_dispatched: resumeDispatched, receipt,
|
|
1240
|
+
...(result ? { result } : {}), next_action: receipt.result && invocationIsTerminal(receipt.result) && receipt.result.state !== 'reconciliation_required' ? 'none' : 'inspect_original' }
|
|
1241
|
+
})
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
const failure = (id && this.localInvocationActionFailure(id, session)) ?? (signal?.aborted || this.disposed
|
|
1244
|
+
? Object.assign(abortError(), { publicCode: 'agent_request_cancelled' }) : error)
|
|
1245
|
+
const feedback = describeFailure(failure, { operation, origin: 'dsh', invocationId, dispatch: resumeDispatched ? 'unknown' : 'not_dispatched' })
|
|
1246
|
+
if (resumeDispatched) {
|
|
1247
|
+
feedback.dispatch = 'unknown'
|
|
1248
|
+
feedback.original_outcome = 'unverified'
|
|
1249
|
+
}
|
|
1250
|
+
if (['transport_unavailable', 'unknown_failure', 'invocation_outcome_unknown'].includes(feedback.category)) {
|
|
1251
|
+
feedback.next_action = 'inspect_original'; feedback.retryable = false; feedback.original_outcome = 'unverified'
|
|
1252
|
+
}
|
|
1253
|
+
const state = feedback.code === 'SESSION_RECOVERY_GAP' ? 'recovery_gap'
|
|
1254
|
+
: feedback.code === 'AUTHORIZATION_UNVERIFIED' ? 'unavailable'
|
|
1255
|
+
: feedback.category === 'invocation_outcome_unknown' && feedback.code !== 'invocation_not_found' ? 'unavailable'
|
|
1256
|
+
: { storage_error: 'storage_error', unsupported: 'unsupported', transport_unavailable: 'unavailable', cancelled: 'cancelled' }[feedback.category] ?? 'blocked'
|
|
1257
|
+
return { schema, operation, ...(INVOCATION_ID.test(invocationId ?? '') ? { invocation_id: invocationId } : {}),
|
|
1258
|
+
state, resume_dispatched: resumeDispatched, reason: feedback.code, feedback }
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async sessionInvocationStatus(sessionId, restore) {
|
|
1263
|
+
const scope = await this.getSessionScope(sessionId)
|
|
1264
|
+
if (scope.mode === 'blocked') {
|
|
1265
|
+
const unsavedRecords = this.invocationJournal.pending.get(sessionId)?.size ?? 0
|
|
1266
|
+
const failure = this.invocationJournal.failures.get(sessionId)
|
|
1267
|
+
const storageFailed = unsavedRecords > 0 || ['invocation_store_unavailable', 'invocation_store_conflict'].includes(failure)
|
|
1268
|
+
return { state: storageFailed ? 'storage_error' : 'blocked',
|
|
1269
|
+
reason: storageFailed ? failure ?? 'invocation_store_unavailable' : scope.reason ?? 'SESSION_SCOPE_UNAVAILABLE',
|
|
1270
|
+
scope_state: 'blocked', scope_reason: scope.reason ?? 'SESSION_SCOPE_UNAVAILABLE', entries: [], unsavedRecords }
|
|
1271
|
+
}
|
|
1272
|
+
if (scope.mode !== 'business' || !scope.locked) return { state: 'inactive', entries: [], unsavedRecords: 0 }
|
|
1273
|
+
// No business resume, new run, tool registration or transcript inference.
|
|
1274
|
+
return this.invocationJournal.status(sessionId, scopeFingerprint(this.sessionScopes.snapshot(sessionId)), restore)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
invocationMetadata(state, run, tool, arguments_, exec, invocationId) {
|
|
1278
|
+
const scope = this.sessionScopes.snapshot(state.sessionId)
|
|
1279
|
+
const member = scope.authorizations.find(item => item.connectionKey === state.authorization?.connectionKey)
|
|
1280
|
+
if (!member) throw recoveryError('invocation_binding_conflict', invocationId)
|
|
1281
|
+
return { id: invocationId, scopeHash: scopeFingerprint(scope), scopeRevision: scope.revision,
|
|
1282
|
+
target: { ...memberBinding(scope, member), connectionKey: member.connectionKey,
|
|
1283
|
+
agentSessionId: member.sessionId, authorizationRef: state.authorization.ref },
|
|
1284
|
+
runId: run.runId, turn: run.turn, callId: exec.callId, tool: tool.name,
|
|
1285
|
+
capabilityRevision: run.capabilityRevision, parameterHash: metadataHash(arguments_),
|
|
1286
|
+
lastKnownState: 'unknown', retryAt: 0, resultHash: null,
|
|
1287
|
+
...(run.taskBinding ? { taskBinding: structuredClone(run.taskBinding) } : {}) }
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async verifyInvocationJournal(state, invocationId, record) {
|
|
1291
|
+
if (!this.invocationJournal.store) return
|
|
1292
|
+
const status = await this.invocationJournal.status(state.sessionId, scopeFingerprint(this.sessionScopes.snapshot(state.sessionId)))
|
|
1293
|
+
if (status.state !== 'ready') throw recoveryError(status.reason ?? 'invocation_store_unavailable', invocationId)
|
|
1294
|
+
if (record?.journalEntry) {
|
|
1295
|
+
const entry = await this.invocationJournal.find(state.sessionId, invocationId, scopeFingerprint(this.sessionScopes.snapshot(state.sessionId)))
|
|
1296
|
+
record.journalEntry = entry
|
|
1297
|
+
record.resumeNotBefore = entry.retryAt
|
|
1298
|
+
if (record.latest && entry.resultHash !== metadataHash(record.latest)) record.latest = undefined
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
async recoverInvocationBinding(state, invocationId) {
|
|
1303
|
+
// Validate ALL original members before using a durable target, including
|
|
1304
|
+
// same-system scopes. A surviving subset is never a recovery target.
|
|
1305
|
+
await this.sessionScopes.get(state.sessionId)
|
|
1306
|
+
await this.sessionScopes.assertUsable(state.sessionId)
|
|
1307
|
+
await this.verifyInvocationJournal(state, invocationId)
|
|
1308
|
+
const scope = this.sessionScopes.snapshot(state.sessionId)
|
|
1309
|
+
const entry = await this.invocationJournal.find(state.sessionId, invocationId, scopeFingerprint(scope))
|
|
1310
|
+
const target = (state.authorizations ?? [state]).find(value => value.authorization?.ref === entry.target.authorizationRef)
|
|
1311
|
+
const member = scope.authorizations.find(value => value.connectionKey === entry.target.connectionKey)
|
|
1312
|
+
const expected = member && { ...memberBinding(scope, member), connectionKey: member.connectionKey,
|
|
1313
|
+
agentSessionId: member.sessionId, authorizationRef: target?.authorization?.ref }
|
|
1314
|
+
if (!target || !member || metadataHash(expected) !== metadataHash(entry.target) ||
|
|
1315
|
+
entry.scopeRevision !== scope.revision || target.authorizedSessionId !== member.sessionId) throw recoveryError('invocation_binding_conflict', invocationId)
|
|
1316
|
+
const taskBinding = await this.sessionTasks.binding(state.sessionId)
|
|
1317
|
+
if (metadataHash(taskBinding ?? null) !== metadataHash(entry.taskBinding ?? null)) throw recoveryError('invocation_binding_conflict', invocationId)
|
|
1318
|
+
const run = { taskBinding, runId: entry.runId, turn: entry.turn, capabilityRevision: entry.capabilityRevision, invocations: new Map() }
|
|
1319
|
+
const record = this.invocationRecord(run, invocationId, { invoked: true, tool: entry.tool })
|
|
1320
|
+
record.journalEntry = entry
|
|
1321
|
+
record.journalSessionId = state.sessionId
|
|
1322
|
+
record.resumeNotBefore = entry.retryAt
|
|
1323
|
+
// A persisted state is a hint, not a fresh business receipt. Query the
|
|
1324
|
+
// original invocation only when the caller explicitly requests resume.
|
|
1325
|
+
const binding = { state: target, run, invocationId }
|
|
1326
|
+
if (state.authorizations) state.authorizedInvocations.set(invocationId, binding)
|
|
1327
|
+
else state.invocationBindings.set(invocationId, binding)
|
|
1328
|
+
return binding
|
|
1329
|
+
}
|
|
1330
|
+
|
|
982
1331
|
async prepareSessionArchive(sessionId) {
|
|
983
1332
|
const scope = await this.sessionScopes.get(sessionId)
|
|
984
1333
|
if (scope.mode !== 'business' || !scope.locked) {
|
|
@@ -1142,6 +1491,8 @@ export class BailingHubAgentClientRuntime {
|
|
|
1142
1491
|
state.status = { state: state.scopeView.state }
|
|
1143
1492
|
return this.decorateAssembly(base, state, removedNames)
|
|
1144
1493
|
}
|
|
1494
|
+
const taskState = await this.sessionTasks.refresh(state.sessionId)
|
|
1495
|
+
state.taskManaged = Boolean(taskState.task_binding || taskState.unsavedRecords || this.sessionTasks.entry(state.sessionId).intendedId || taskState.reason === 'TASK_REQUIRED')
|
|
1145
1496
|
this.initializeAuthorizations(state)
|
|
1146
1497
|
const started = await this.startRun(state, turn, context.signal)
|
|
1147
1498
|
removedNames = started.removedNames
|
|
@@ -1280,8 +1631,11 @@ export class BailingHubAgentClientRuntime {
|
|
|
1280
1631
|
if (!state.authorization) throw scopeError()
|
|
1281
1632
|
if (state.parentState?.disposed) throw abortError()
|
|
1282
1633
|
// Cross-system dispatch must not continue with only the surviving subset.
|
|
1283
|
-
if (state.parentState?.crossSystem) await this.sessionScopes.get(state.sessionId)
|
|
1634
|
+
if (state.parentState?.crossSystem || state.parentState?.sessionToolCache) await this.sessionScopes.get(state.sessionId)
|
|
1284
1635
|
await this.sessionScopes.assertUsable(state.sessionId)
|
|
1636
|
+
// Managed whole-scope resolution has just checked this exact target too.
|
|
1637
|
+
// Do not issue a second status request for a member of the same proof.
|
|
1638
|
+
if (state.parentState?.taskManaged) return
|
|
1285
1639
|
let status
|
|
1286
1640
|
try {
|
|
1287
1641
|
const transport = await this.getTransport()
|
|
@@ -1300,7 +1654,10 @@ export class BailingHubAgentClientRuntime {
|
|
|
1300
1654
|
}
|
|
1301
1655
|
|
|
1302
1656
|
initializeAuthorizations(state) {
|
|
1303
|
-
if (state.authorizationInit) return
|
|
1657
|
+
if (state.authorizationInit && (!state.taskManaged || state.sessionToolCache)) return
|
|
1658
|
+
// A failed pre-run optional session may subsequently receive its explicit
|
|
1659
|
+
// managed association. Its next turn must still use lazy target preparation.
|
|
1660
|
+
if (state.authorizationInit) { delete state.authorization; delete state.authorizedSessionId }
|
|
1304
1661
|
const snapshot = this.sessionScopes.snapshot(state.sessionId)
|
|
1305
1662
|
const entries = state.scopeView.authorizations.map((item) => ({
|
|
1306
1663
|
...item, ref: item.authorizationRef,
|
|
@@ -1308,20 +1665,22 @@ export class BailingHubAgentClientRuntime {
|
|
|
1308
1665
|
}))
|
|
1309
1666
|
state.binding = snapshot.binding
|
|
1310
1667
|
state.crossSystem = isCrossSystemScope(snapshot)
|
|
1311
|
-
if (entries.length === 1) {
|
|
1668
|
+
if (entries.length === 1 && !(this.sessionToolReuse || state.taskManaged)) {
|
|
1312
1669
|
state.authorization = entries[0]
|
|
1313
1670
|
state.authorizedSessionId = entries[0].sessionId
|
|
1314
1671
|
state.workspace = entries[0].workspace
|
|
1315
1672
|
state.connectionName = undefined
|
|
1316
1673
|
} else {
|
|
1674
|
+
if (this.sessionToolReuse || state.taskManaged) state.sessionToolCache = new SessionToolCache()
|
|
1317
1675
|
state.authorizations = entries.map((authorization) => {
|
|
1318
1676
|
const target = createState(state.agent, authorization.workspace, undefined)
|
|
1319
1677
|
target.authorization = authorization
|
|
1320
1678
|
target.authorizedSessionId = authorization.sessionId
|
|
1321
|
-
if (state.crossSystem) {
|
|
1679
|
+
if (state.crossSystem || (this.sessionToolReuse || state.taskManaged)) {
|
|
1322
1680
|
const saved = snapshot.authorizations.find((item) => item.connectionKey === authorization.connectionKey)
|
|
1323
1681
|
target.expectedBinding = { ...memberBinding(snapshot, saved), sessionId: saved.sessionId }
|
|
1324
1682
|
}
|
|
1683
|
+
if (this.sessionToolReuse || state.taskManaged) target.toolCacheKey = digestId(scopeFingerprint(snapshot), authorization.ref, authorization.connectionKey, authorization.sessionId)
|
|
1325
1684
|
target.parentState = state
|
|
1326
1685
|
target.pendingClaims = state.pendingClaims
|
|
1327
1686
|
return target
|
|
@@ -1335,12 +1694,15 @@ export class BailingHubAgentClientRuntime {
|
|
|
1335
1694
|
const removedNames = new Set(state.knownToolNames)
|
|
1336
1695
|
await this.clearActiveTools(state)
|
|
1337
1696
|
state.currentRun?.abortController?.abort()
|
|
1338
|
-
const group = { turn, multi: true, crossSystem: state.crossSystem,
|
|
1697
|
+
const group = { turn, multi: true, crossSystem: state.crossSystem, sessionTools: (this.sessionToolReuse || state.taskManaged),
|
|
1698
|
+
single: (this.sessionToolReuse || state.taskManaged) && state.authorizations.length === 1,
|
|
1699
|
+
lazy: state.crossSystem || (this.sessionToolReuse || state.taskManaged), status: 'active', targets: [], calls: new Map(),
|
|
1700
|
+
...((this.sessionToolReuse || state.taskManaged) ? { preparedCalls: new Map() } : {}),
|
|
1339
1701
|
userMessageId: state.pendingClaims.get(turn)?.[0]?.id,
|
|
1340
|
-
...(state.crossSystem ? { abortController: new AbortController() } : {}) }
|
|
1702
|
+
...(state.crossSystem || (this.sessionToolReuse || state.taskManaged) ? { abortController: new AbortController() } : {}) }
|
|
1341
1703
|
state.runs.set(turn, group)
|
|
1342
1704
|
state.currentRun = group
|
|
1343
|
-
if (
|
|
1705
|
+
if (group.lazy) {
|
|
1344
1706
|
// A selected directory grants a choice, not a broadcast of this turn.
|
|
1345
1707
|
group.targets = state.authorizations.map((target) => ({ state: target, run: undefined }))
|
|
1346
1708
|
} else {
|
|
@@ -1359,7 +1721,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1359
1721
|
try {
|
|
1360
1722
|
if (state.disposed) throw abortError()
|
|
1361
1723
|
await this.replaceAuthorizedTools(state, group)
|
|
1362
|
-
state.status = { state: 'active', authorizationCount: group.targets.length, activeToolCount:
|
|
1724
|
+
state.status = { state: 'active', authorizationCount: group.targets.length, activeToolCount: this.getSessionToolState(state.sessionId).active_tools.length }
|
|
1363
1725
|
} catch (error) {
|
|
1364
1726
|
group.status = 'degraded'
|
|
1365
1727
|
group.degradedReason = 'shared authorization tools are unavailable'
|
|
@@ -1371,32 +1733,35 @@ export class BailingHubAgentClientRuntime {
|
|
|
1371
1733
|
|
|
1372
1734
|
async assertActiveGroup(state, group, exec) {
|
|
1373
1735
|
await this.sessionScopes.assertUsable(state.sessionId)
|
|
1374
|
-
if (
|
|
1736
|
+
if (exec.signal?.aborted || state.disposed || state.endedTurns.has(group.turn) || group.status !== 'active') throw abortError()
|
|
1737
|
+
if (exec.agent !== state.agent || state.currentRun !== group) {
|
|
1375
1738
|
throw new Error('BailingHub authorization tools are not active for this session turn')
|
|
1376
1739
|
}
|
|
1377
1740
|
this.sessionScopes.assertCurrent(state.sessionId)
|
|
1378
1741
|
}
|
|
1379
1742
|
|
|
1380
1743
|
async replaceAuthorizedTools(state, group, preferredNames = [], preferredRefs = []) {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1744
|
+
if (group.sessionTools) return replaceSessionTools(this, state, group, preferredNames, preferredRefs, normalizeCapabilitySearchResponse)
|
|
1745
|
+
const candidates = sharedDeclarations(group.targets.filter(({ run }) => run?.status === 'active').map(target => target.state), Infinity, preferredNames, group.crossSystem, preferredRefs)
|
|
1746
|
+
// Search hits and recently used tools get a place in the small schema window.
|
|
1747
|
+
const hits = candidates.tools.filter(entry => preferredNames.includes(entry.tool.name) &&
|
|
1748
|
+
(!preferredRefs.length || entry.targets.some(target => preferredRefs.includes(target.authorization.ref))))
|
|
1749
|
+
for (const entry of [...hits].reverse()) this.touchTool(state, entry.name)
|
|
1750
|
+
candidates.tools.sort((a, b) => (state.toolUsage.get(b.name) ?? 0) - (state.toolUsage.get(a.name) ?? 0))
|
|
1751
|
+
group.shared = { ...candidates, tools: candidates.tools.slice(0, RETAINED_TOOL_LIMIT),
|
|
1752
|
+
omitted: Math.max(0, candidates.tools.length - RETAINED_TOOL_LIMIT) }
|
|
1753
|
+
state.visibleToolNames = group.shared.tools.slice(0, VISIBLE_TOOL_LIMIT).map(entry => entry.name)
|
|
1754
|
+
const definitions = group.shared.tools.map(shared => this.authorizedDefinition(state, group, shared))
|
|
1755
|
+
definitions.push(this.authorizedSearchDefinition(state, group), this.authorizedResumeDefinition(state, group), this.inspectDefinitionFor(state, group))
|
|
1756
|
+
this.registerArtifactTools(state, group, definitions)
|
|
1757
|
+
await this.reconcileDefinitions(state, group, definitions)
|
|
1393
1758
|
}
|
|
1394
1759
|
|
|
1395
1760
|
authorizedDefinition(state, group, shared) {
|
|
1396
1761
|
const { tool } = shared
|
|
1397
1762
|
return {
|
|
1398
1763
|
name: shared.name,
|
|
1399
|
-
description: `${tool.description}\n${group.crossSystem ? `System ${shared.targets[0].authorization.systemRef}; original capability ${tool.name}. ` : ''}Choose the authorized account for this call. scope=${tool.scope}, risk=${tool.risk}, approval_required=${tool.approvalRequired}, readonly=${tool.readonly}, idempotent=${tool.idempotent}.`,
|
|
1764
|
+
description: `${tool.description}\n${group.crossSystem ? `System ${shared.targets[0].authorization.systemRef}; original capability ${tool.name}. ` : ''}Choose the authorized account for this call. scope=${tool.scope}, risk=${tool.risk}, approval_required=${tool.approvalRequired}, readonly=${tool.readonly}, idempotent=${tool.idempotent}. Use this loaded tool directly when the target is clear. If rate limited before dispatch, wait for retry_after_ms then resume the original invocation_id. On uncertain outcomes recover the original invocation_id; never create a replacement call.`,
|
|
1400
1765
|
parameters: authorizationEnvelope(tool, shared.targets.map((target) => target.authorization.ref)),
|
|
1401
1766
|
output: { schema: {}, render: (_arguments, value) => [{ type: 'text', text: safeJson(value) }] },
|
|
1402
1767
|
execute: async (value, exec) => {
|
|
@@ -1406,24 +1771,27 @@ export class BailingHubAgentClientRuntime {
|
|
|
1406
1771
|
const signature = invocationSignature(selected.ref, tool.name, selected.arguments)
|
|
1407
1772
|
let binding = group.calls.get(exec.callId)
|
|
1408
1773
|
if (binding && binding.signature !== signature) {
|
|
1409
|
-
throw new Error('BailingHub invocation replay changed the original authorization, tool or arguments')
|
|
1774
|
+
throw Object.assign(new Error('BailingHub invocation replay changed the original authorization, tool or arguments'), { publicCode: 'agent_invalid_request' })
|
|
1410
1775
|
}
|
|
1411
1776
|
if (!binding) {
|
|
1412
1777
|
const target = shared.targets.find((entry) => entry.authorization.ref === selected.ref)
|
|
1413
1778
|
if (!target) throw new Error('This tool is not available under the selected authorization')
|
|
1414
1779
|
const current = target.currentRun.activeTools.find((entry) => entry.name === tool.name)
|
|
1415
|
-
if (target.currentRun.status !== 'active' || !current || declarationKey(current) !== shared.key) {
|
|
1416
|
-
throw new Error('This tool declaration is no longer active for the selected authorization')
|
|
1780
|
+
if (!state.activeDefinitions.has(shared.name) || target.currentRun.status !== 'active' || !current || declarationKey(current) !== shared.key) {
|
|
1781
|
+
throw Object.assign(new Error('This tool declaration is no longer active for the selected authorization'), { publicCode: 'tool_not_loaded', feedback: describeFailure({ publicCode: 'tool_not_loaded' }, { operation: 'invoke', dispatch: 'not_dispatched' }) })
|
|
1417
1782
|
}
|
|
1418
1783
|
// Freeze the revision as well as the key. A later search or default switch
|
|
1419
1784
|
// must never reinterpret a replayed DSH call.
|
|
1420
1785
|
const run = { ...target.currentRun, invocations: target.currentRun.invocations }
|
|
1421
|
-
binding = { signature, state: target, run, tool, arguments: selected.arguments }
|
|
1786
|
+
binding = { signature, state: target, run, tool, arguments: selected.arguments, registeredName: shared.name }
|
|
1422
1787
|
group.calls.set(exec.callId, binding)
|
|
1423
1788
|
const invocationId = digestId(target.sessionId, run.runId, exec.callId, tool.name)
|
|
1789
|
+
binding.invocationId = invocationId
|
|
1424
1790
|
state.authorizedInvocations.set(invocationId, binding)
|
|
1425
1791
|
target.currentRun.toolCallIds.add(exec.callId)
|
|
1426
1792
|
}
|
|
1793
|
+
this.touchTool(state, shared.name)
|
|
1794
|
+
touchCatalogTool(binding.state.currentRun, tool.name)
|
|
1427
1795
|
const result = await this.executeTrackedInvocation(binding.state, binding.run, binding.tool, binding.arguments, exec)
|
|
1428
1796
|
return authorizedResult(binding.state, result)
|
|
1429
1797
|
},
|
|
@@ -1434,8 +1802,8 @@ export class BailingHubAgentClientRuntime {
|
|
|
1434
1802
|
return {
|
|
1435
1803
|
name: 'search_business_capabilities',
|
|
1436
1804
|
description: group.crossSystem
|
|
1437
|
-
? 'Choose one authorized business target and find its capabilities. authorization_ref is required. query is the minimal task description sent to that target to open this turn; omit unrelated conversation and other systems\' data. This grants no new authority.'
|
|
1438
|
-
: 'Find shared business tools within this session\'s authorizations. Optionally restrict the search to one authorization. This grants no new authority.',
|
|
1805
|
+
? 'Choose one authorized business target and find its capabilities. authorization_ref is required. query is the minimal task description sent to that target to open this turn; omit unrelated conversation and other systems\' data. This grants no new authority.' + ' ' + DISCOVERY_GUIDANCE
|
|
1806
|
+
: 'Find shared business tools within this session\'s authorizations. Optionally restrict the search to one authorization. This grants no new authority.' + ' ' + DISCOVERY_GUIDANCE,
|
|
1439
1807
|
parameters: {
|
|
1440
1808
|
type: 'object', properties: {
|
|
1441
1809
|
query: { type: 'string' }, limit: { type: 'integer', description: 'Maximum results from 1 to 12.' },
|
|
@@ -1454,7 +1822,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1454
1822
|
const targets = group.targets.filter(({ state: target }) =>
|
|
1455
1823
|
value.authorization_ref === undefined || target.authorization.ref === value.authorization_ref)
|
|
1456
1824
|
if (!targets.length) throw new Error('Unknown authorization_ref')
|
|
1457
|
-
// Serialize
|
|
1825
|
+
// Serialize discovery updates while allowing independent business calls.
|
|
1458
1826
|
const search = async () => {
|
|
1459
1827
|
await this.assertActiveGroup(state, group, exec)
|
|
1460
1828
|
const names = []
|
|
@@ -1474,19 +1842,21 @@ export class BailingHubAgentClientRuntime {
|
|
|
1474
1842
|
const result = normalizeCapabilitySearchResponse(await transport.searchCapabilities(
|
|
1475
1843
|
{ query, limit, runId: run.runId }, this.connectionOptions(target, exec.signal),
|
|
1476
1844
|
))
|
|
1477
|
-
|
|
1478
|
-
|
|
1845
|
+
await this.assertActiveGroup(state, group, exec)
|
|
1846
|
+
if (exec.signal?.aborted) throw abortError()
|
|
1847
|
+
const update = mergeDiscoveredTools(run, result)
|
|
1479
1848
|
names.push(...result.tools.map((tool) => tool.name))
|
|
1480
|
-
results.push({ authorization_ref: target.authorization.ref, state: 'available' })
|
|
1481
|
-
} catch {
|
|
1482
|
-
|
|
1483
|
-
results.push({ authorization_ref: target.authorization.ref, state: 'unavailable' })
|
|
1849
|
+
results.push({ authorization_ref: target.authorization.ref, state: 'available', discovery: result.discovery, ...update })
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
// A failed search does not prove that previously discovered tools vanished.
|
|
1852
|
+
results.push({ authorization_ref: target.authorization.ref, state: 'unavailable', feedback: describeFailure(error, { operation: 'search' }) })
|
|
1484
1853
|
}
|
|
1485
1854
|
}
|
|
1486
1855
|
await this.assertActiveGroup(state, group, exec)
|
|
1487
1856
|
await this.replaceAuthorizedTools(state, group, names, targets.map(({ state: target }) => target.authorization.ref))
|
|
1488
1857
|
return {
|
|
1489
1858
|
authorizations: results,
|
|
1859
|
+
...this.getSessionToolState(state.sessionId),
|
|
1490
1860
|
active_tools: group.shared.tools.map(({ tool, name, targets }) => ({
|
|
1491
1861
|
name, authorization_refs: targets.map((target) => target.authorization.ref),
|
|
1492
1862
|
...(group.crossSystem ? { original_name: tool.name, system_ref: targets[0].authorization.systemRef } : {}),
|
|
@@ -1509,10 +1879,10 @@ export class BailingHubAgentClientRuntime {
|
|
|
1509
1879
|
if (!value || Object.keys(value).some((key) => key !== 'invocation_id') || !INVOCATION_ID.test(value.invocation_id ?? '')) {
|
|
1510
1880
|
throw new TypeError('Only the original invocation_id may be supplied for recovery')
|
|
1511
1881
|
}
|
|
1512
|
-
const binding = state.authorizedInvocations.get(value.invocation_id)
|
|
1513
|
-
|
|
1882
|
+
const binding = state.authorizedInvocations.get(value.invocation_id) ?? await this.recoverInvocationBinding(state, value.invocation_id)
|
|
1883
|
+
await this.assertActiveGroup(state, group, exec)
|
|
1514
1884
|
const entry = group.targets.find(({ state: target }) => target === binding.state)
|
|
1515
|
-
if (group.
|
|
1885
|
+
if (group.lazy && !entry.run && !binding.run.taskBinding) {
|
|
1516
1886
|
await this.ensureAuthorizedTarget(state, group, entry, `Check the original governed invocation ${value.invocation_id}; do not execute it again.`, exec)
|
|
1517
1887
|
}
|
|
1518
1888
|
const currentRun = entry.run
|
|
@@ -1521,21 +1891,29 @@ export class BailingHubAgentClientRuntime {
|
|
|
1521
1891
|
currentRun.recoveries.set(value.invocation_id, binding.run.invocations.get(value.invocation_id))
|
|
1522
1892
|
}
|
|
1523
1893
|
const result = await this.resumeTrackedInvocation(binding.state, binding.run, value.invocation_id, exec.signal)
|
|
1524
|
-
return authorizedResult(binding.state, result)
|
|
1894
|
+
return group.single ? result : authorizedResult(binding.state, result)
|
|
1525
1895
|
}
|
|
1526
1896
|
return definition
|
|
1527
1897
|
}
|
|
1528
1898
|
|
|
1529
1899
|
async ensureAuthorizedTarget(state, group, entry, task, exec) {
|
|
1530
1900
|
await this.assertActiveGroup(state, group, exec)
|
|
1901
|
+
if (group.sessionTools && entry.run?.status === 'degraded' &&
|
|
1902
|
+
entry.run.failure?.feedback?.category === 'transport_unavailable') {
|
|
1903
|
+
entry.startPromise = undefined
|
|
1904
|
+
entry.run = undefined
|
|
1905
|
+
}
|
|
1531
1906
|
if (!entry.startPromise && !entry.run) {
|
|
1907
|
+
// startTurn is idempotent on the original client turn. A response loss
|
|
1908
|
+
// must retry that exact projection, never silently replace its input.
|
|
1909
|
+
entry.projectedClaim ??= { id: group.userMessageId, text: task }
|
|
1532
1910
|
entry.startPromise = this.startRunOnce(entry.state, group.turn, exec.signal, false,
|
|
1533
|
-
|
|
1911
|
+
entry.projectedClaim).then(() => { entry.run = entry.state.runs.get(group.turn) })
|
|
1534
1912
|
}
|
|
1535
1913
|
await entry.startPromise
|
|
1536
1914
|
// A late original response only retains its link; it cannot reopen tools.
|
|
1537
1915
|
await this.assertActiveGroup(state, group, exec)
|
|
1538
|
-
if (entry.run?.status !== 'active') throw new Error('The selected target has no active run')
|
|
1916
|
+
if (entry.run?.status !== 'active') throw entry.run?.failure ?? new Error('The selected target has no active run')
|
|
1539
1917
|
}
|
|
1540
1918
|
|
|
1541
1919
|
async startRunOnce(state, turn, signal, registerTools = true, projectedClaim) {
|
|
@@ -1554,15 +1932,19 @@ export class BailingHubAgentClientRuntime {
|
|
|
1554
1932
|
const primary = claims[0]
|
|
1555
1933
|
const userInput = claims.map((claim) => claim.text).filter(Boolean).join('\n\n')
|
|
1556
1934
|
const staleTurn = () => (state.parentState ?? state).endedTurns.has(turn) ||
|
|
1557
|
-
state.currentRun?.turn > turn || state.parentState?.currentRun?.turn > turn
|
|
1935
|
+
state.currentRun?.turn > turn || state.parentState?.currentRun?.turn > turn ||
|
|
1936
|
+
(state.parentState?.sessionToolCache && signal?.aborted)
|
|
1558
1937
|
|
|
1559
1938
|
try {
|
|
1560
1939
|
const transport = await this.getTransport()
|
|
1561
|
-
await this.assertAuthorization(state)
|
|
1940
|
+
if (!(state.parentState ?? state).taskManaged) await this.assertAuthorization(state)
|
|
1562
1941
|
if (staleTurn()) {
|
|
1563
1942
|
this.recordEndedRun(state, turn)
|
|
1564
1943
|
return { removedNames }
|
|
1565
1944
|
}
|
|
1945
|
+
const taskBinding = await this.sessionTasks.binding(state.sessionId, { dispatch: true })
|
|
1946
|
+
if (taskBinding && !this.invocationJournal.store) throw taskError('TASK_STORE_UNAVAILABLE')
|
|
1947
|
+
if (staleTurn()) { this.recordEndedRun(state, turn); return { removedNames } }
|
|
1566
1948
|
const response = await transport.startTurn(
|
|
1567
1949
|
{
|
|
1568
1950
|
clientConversationId: messageAlias('conversation', state.sessionId),
|
|
@@ -1572,8 +1954,10 @@ export class BailingHubAgentClientRuntime {
|
|
|
1572
1954
|
},
|
|
1573
1955
|
{
|
|
1574
1956
|
...this.connectionOptions(state, signal),
|
|
1957
|
+
...(taskBinding ? { taskBinding } : {}),
|
|
1575
1958
|
},
|
|
1576
1959
|
)
|
|
1960
|
+
if (metadataHash(response.task_binding ?? null) !== metadataHash(taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
1577
1961
|
const normalized = normalizeStartTurnResponse(response)
|
|
1578
1962
|
// Record the original response linkage even if authorization is revoked
|
|
1579
1963
|
// or the local turn is cancelled before its late response is processed.
|
|
@@ -1592,6 +1976,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1592
1976
|
const run = {
|
|
1593
1977
|
turn,
|
|
1594
1978
|
status: 'active',
|
|
1979
|
+
taskBinding,
|
|
1595
1980
|
...normalized,
|
|
1596
1981
|
assistant: undefined,
|
|
1597
1982
|
usages: [],
|
|
@@ -1600,6 +1985,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1600
1985
|
completeSent: false,
|
|
1601
1986
|
completion: undefined,
|
|
1602
1987
|
}
|
|
1988
|
+
if (state.parentState?.sessionToolCache) captureSessionTools(state.parentState, state, run)
|
|
1603
1989
|
if (registerTools) await this.replaceActiveTools(state, run)
|
|
1604
1990
|
state.runs.set(turn, run)
|
|
1605
1991
|
state.currentRun = run
|
|
@@ -1641,29 +2027,149 @@ export class BailingHubAgentClientRuntime {
|
|
|
1641
2027
|
}
|
|
1642
2028
|
}
|
|
1643
2029
|
|
|
1644
|
-
async replaceActiveTools(state, run) {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2030
|
+
async replaceActiveTools(state, run, preferredNames = []) {
|
|
2031
|
+
for (const name of [...preferredNames].reverse()) this.touchTool(state, name)
|
|
2032
|
+
const sorted = [...run.activeTools].sort((a, b) => (state.toolUsage.get(b.name) ?? 0) - (state.toolUsage.get(a.name) ?? 0))
|
|
2033
|
+
state.visibleToolNames = sorted.slice(0, VISIBLE_TOOL_LIMIT).map(tool => tool.name)
|
|
2034
|
+
const definitions = sorted.map(tool => this.definitionFor(state, run, tool))
|
|
2035
|
+
definitions.push(this.searchDefinitionFor(state, run), this.resumeDefinitionFor(state, run), this.inspectDefinitionFor(state, run))
|
|
2036
|
+
this.registerArtifactTools(state, run, definitions)
|
|
2037
|
+
await this.reconcileDefinitions(state, run, definitions)
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
touchTool(state, name) {
|
|
2041
|
+
state.toolUsage.set(name, ++state.toolUseSequence)
|
|
2042
|
+
if (state.activeDefinitions.has(name)) {
|
|
2043
|
+
state.visibleToolNames = [name, ...state.visibleToolNames.filter(entry => entry !== name)].slice(0, VISIBLE_TOOL_LIMIT)
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
assertRegistryTurn(state, run) {
|
|
2048
|
+
if (state.disposed || run.status !== 'active' || state.endedTurns.has(run.turn) ||
|
|
2049
|
+
(state.currentRun && state.currentRun !== run && state.currentRun.turn >= run.turn)) throw abortError()
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
async reconcileDefinitions(state, run, definitions) {
|
|
2053
|
+
this.assertRegistryTurn(state, run)
|
|
2054
|
+
const desired = new Map(definitions.map(definition => [definition.name, definition]))
|
|
2055
|
+
// Reuse unchanged registrations. Only retired or changed schemas are removed;
|
|
2056
|
+
// unrelated tools and in-flight invocation bindings remain intact.
|
|
2057
|
+
for (const [name, current] of state.activeDefinitions) {
|
|
2058
|
+
const next = desired.get(name)
|
|
2059
|
+
if (next && declarationKey(toolSchema(current)) === declarationKey(toolSchema(next))) continue
|
|
2060
|
+
const dispose = state.activeDisposers.get(name)
|
|
2061
|
+
state.activeDisposers.delete(name)
|
|
2062
|
+
state.activeDefinitions.delete(name)
|
|
2063
|
+
state.definitionSlots.delete(name)
|
|
2064
|
+
state.toolsetGeneration += 1
|
|
2065
|
+
await dispose?.()
|
|
2066
|
+
this.assertRegistryTurn(state, run)
|
|
1655
2067
|
}
|
|
2068
|
+
for (const definition of definitions) {
|
|
2069
|
+
this.assertRegistryTurn(state, run)
|
|
2070
|
+
this.registerDefinition(state, definition, run)
|
|
2071
|
+
}
|
|
2072
|
+
state.toolsetGeneration += 1
|
|
1656
2073
|
}
|
|
1657
2074
|
|
|
1658
|
-
|
|
2075
|
+
registerArtifactTools(state, run, definitions) {
|
|
2076
|
+
const register = definition => definitions ? definitions.push(definition) : this.registerDefinition(state, definition)
|
|
2077
|
+
if (!this.artifactSource) return
|
|
2078
|
+
const assertActive = async (exec) => {
|
|
2079
|
+
await this.sessionScopes.assertUsable(state.sessionId)
|
|
2080
|
+
if (exec.agent !== state.agent || state.disposed || state.currentRun !== run || run.status !== 'active' || state.endedTurns.has(run.turn) || exec.signal?.aborted) throw abortError()
|
|
2081
|
+
}
|
|
2082
|
+
register({
|
|
2083
|
+
name: 'list_generated_artifacts',
|
|
2084
|
+
description: 'List images registered by this host and permitted for business use in this conversation, including generated images and user-provided images selected for that purpose. Returns opaque artifact references; never invent references or use local paths. Listing uploads no files. Chat attachments are not automatically registered or uploaded.',
|
|
2085
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
2086
|
+
output: { schema: {}, render: (_args, value) => [{ type: 'text', text: safeJson(value) }] },
|
|
2087
|
+
execute: async (args, exec) => {
|
|
2088
|
+
await assertActive(exec)
|
|
2089
|
+
if (Object.keys(args ?? {}).length) throw new TypeError('No parameters allowed')
|
|
2090
|
+
const artifacts = await listArtifacts(this.artifactSource, state.sessionId)
|
|
2091
|
+
await assertActive(exec)
|
|
2092
|
+
return { artifacts }
|
|
2093
|
+
},
|
|
2094
|
+
})
|
|
2095
|
+
const targets = state.authorizations ?? [state]
|
|
2096
|
+
register({
|
|
2097
|
+
name: 'upload_generated_artifacts',
|
|
2098
|
+
description: 'Upload 1–8 registered images to ONE explicitly selected authorization when business use requires public URLs. Images may be generated or user-provided and explicitly selected for business use; they need not originate in this turn. Do not upload attachments merely because they appear in chat. Returns each image URL or error. Reuse ready URLs; retry pending items with the SAME artifact_ref and authorization, never rename or regenerate to bypass an error. The runtime handles recoverable upload run-link errors. Uploading does not update any product. Wait for every required image to be ready before submitting a complete gallery. Keep existing images unless the user asked to remove them. Business approvals still apply; uncertain business writes must resume their original invocation_id.',
|
|
2099
|
+
parameters: { type: 'object', properties: {
|
|
2100
|
+
authorization_ref: { type: 'string', enum: targets.map(x => x.authorization.ref) },
|
|
2101
|
+
artifact_refs: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8, uniqueItems: true },
|
|
2102
|
+
}, required: ['authorization_ref', 'artifact_refs'], additionalProperties: false },
|
|
2103
|
+
output: { schema: {}, render: (_args, value) => [{ type: 'text', text: safeJson(value) }] },
|
|
2104
|
+
execute: async (args, exec) => {
|
|
2105
|
+
await assertActive(exec)
|
|
2106
|
+
const target = targets.find(x => x.authorization.ref === args?.authorization_ref)
|
|
2107
|
+
const refs = args?.artifact_refs
|
|
2108
|
+
if (!target || !Array.isArray(refs) || !refs.length || refs.length > 8 || new Set(refs).size !== refs.length || refs.some(x => typeof x !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(x)) || Object.keys(args).some(k => !['authorization_ref', 'artifact_refs'].includes(k))) throw new TypeError('Explicit selected authorization and registered artifact references are required')
|
|
2109
|
+
const snapshot = this.sessionScopes.snapshot(state.sessionId)
|
|
2110
|
+
const member = snapshot.authorizations.find(x => x.connectionKey === target.authorization.connectionKey)
|
|
2111
|
+
const binding = { ...memberBinding(snapshot, member), sessionId: member.sessionId }
|
|
2112
|
+
const transport = await this.getTransport()
|
|
2113
|
+
const options = { ...this.connectionOptions(target, exec.signal), expectedBinding: binding }
|
|
2114
|
+
// Cross-system targets are lazy: currentRun may still be from an older
|
|
2115
|
+
// turn. Uploading alone neither starts a business run nor links that old run.
|
|
2116
|
+
const targetRun = target.runs.get(run.turn)
|
|
2117
|
+
const runId = targetRun?.status === 'active' && targetRun.turn === run.turn ? targetRun.runId : undefined
|
|
2118
|
+
const results = []
|
|
2119
|
+
for (const artifactRef of refs) {
|
|
2120
|
+
try {
|
|
2121
|
+
results.push(await deliverArtifact({ source: this.artifactSource, store: this.artifactStore, transport,
|
|
2122
|
+
sessionId: state.sessionId, artifactRef, binding, options,
|
|
2123
|
+
metadata: { clientConversationId: messageAlias('conversation', state.sessionId), clientTurnId: messageAlias('turn', state.sessionId, String(run.turn)),
|
|
2124
|
+
...(runId ? { runId } : {}) },
|
|
2125
|
+
assertActive: async () => { await assertActive(exec); await this.assertAuthorization(target); await assertActive(exec) },
|
|
2126
|
+
}))
|
|
2127
|
+
} catch (error) { results.push(artifactFailure(error, artifactRef)) }
|
|
2128
|
+
}
|
|
2129
|
+
// A cancelled/ended turn cannot return an actionable URL or restore removed tools.
|
|
2130
|
+
await assertActive(exec)
|
|
2131
|
+
return { authorization_ref: target.authorization.ref, results, all_ready: results.every(x => x.state === 'ready'), business_operation_performed: false }
|
|
2132
|
+
},
|
|
2133
|
+
})
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
registerDefinition(state, definition, run = state.currentRun) {
|
|
1659
2137
|
const existing = state.agent.ctx.tools.get?.(definition.name, state.agent)
|
|
1660
|
-
|
|
1661
|
-
|
|
2138
|
+
const slot = state.definitionSlots.get(definition.name)
|
|
2139
|
+
if (slot && existing === state.activeDefinitions.get(definition.name)) {
|
|
2140
|
+
// The schema is identical. Capture the latest definition at call entry;
|
|
2141
|
+
// never mutate the binding of a call that is already running.
|
|
2142
|
+
slot.current = definition
|
|
2143
|
+
return
|
|
1662
2144
|
}
|
|
1663
|
-
|
|
1664
|
-
|
|
2145
|
+
if (existing !== undefined) throw new Error(`BailingHub tool conflicts with an existing DSH tool: ${definition.name}`)
|
|
2146
|
+
const nextSlot = { current: definition, run }
|
|
2147
|
+
const registered = { ...definition, execute: async (args, exec) => {
|
|
2148
|
+
const current = nextSlot.current
|
|
2149
|
+
try {
|
|
2150
|
+
if (exec.agent !== state.agent) throw Object.assign(new Error('The tool belongs to another session'),
|
|
2151
|
+
{ feedback: describeFailure({ code: 'agent_invalid_request' }, { operation: 'invoke', dispatch: 'not_dispatched' }) })
|
|
2152
|
+
if (state.definitionSlots.get(definition.name) !== nextSlot) {
|
|
2153
|
+
const feedback = this.originalCallFeedback(nextSlot.run, definition.name, exec.callId) ??
|
|
2154
|
+
describeFailure({ code: 'tool_not_loaded' }, { operation: 'invoke', dispatch: 'not_dispatched' })
|
|
2155
|
+
throw Object.assign(new Error('This tool registration was retired'), { feedback })
|
|
2156
|
+
}
|
|
2157
|
+
return await current.execute(args, exec)
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
const operation = definition.name === 'search_business_capabilities' ? 'search'
|
|
2160
|
+
: definition.name === 'resume_governed_tool_invocation' ? 'resume' : definition.name === 'inspect_governed_tool_invocation' ? 'inspect' : 'invoke'
|
|
2161
|
+
const failure = publicFailure(operation, error)
|
|
2162
|
+
if (exec && typeof exec === 'object') this.toolFailures.set(exec, failure.feedback)
|
|
2163
|
+
throw failure
|
|
2164
|
+
}
|
|
2165
|
+
} }
|
|
2166
|
+
const disposer = state.agent.ctx.tools.register(registered)
|
|
2167
|
+
state.definitionSlots.set(definition.name, nextSlot)
|
|
2168
|
+
state.activeDefinitions.set(definition.name, registered)
|
|
1665
2169
|
state.activeDisposers.set(definition.name, disposer)
|
|
1666
2170
|
state.knownToolNames.add(definition.name)
|
|
2171
|
+
state.toolsetGeneration += 1
|
|
2172
|
+
state.status = { ...state.status, activeToolCount: [...state.activeDefinitions.keys()].filter(name => !RESERVED_TOOLS.has(name)).length }
|
|
1667
2173
|
}
|
|
1668
2174
|
|
|
1669
2175
|
invocationRecord(run, invocationId, details = {}) {
|
|
@@ -1692,9 +2198,19 @@ export class BailingHubAgentClientRuntime {
|
|
|
1692
2198
|
return record
|
|
1693
2199
|
}
|
|
1694
2200
|
|
|
1695
|
-
|
|
2201
|
+
serializeInvocation(sessionId, invocationId, operation) {
|
|
2202
|
+
const key = `${sessionId}:${invocationId}`
|
|
2203
|
+
const previous = this.invocationOperations.get(key) ?? Promise.resolve()
|
|
2204
|
+
const result = previous.then(operation)
|
|
2205
|
+
const tail = result.catch(() => {})
|
|
2206
|
+
this.invocationOperations.set(key, tail)
|
|
2207
|
+
void tail.finally(() => { if (this.invocationOperations.get(key) === tail) this.invocationOperations.delete(key) })
|
|
2208
|
+
return result
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
runInvocationOperation(record, operation, sessionId) {
|
|
1696
2212
|
if (record.operation) return record.operation
|
|
1697
|
-
const promise =
|
|
2213
|
+
const promise = this.serializeInvocation(sessionId, record.invocationId, operation)
|
|
1698
2214
|
record.operation = promise
|
|
1699
2215
|
void promise.finally(() => {
|
|
1700
2216
|
if (record.operation === promise) record.operation = undefined
|
|
@@ -1702,45 +2218,89 @@ export class BailingHubAgentClientRuntime {
|
|
|
1702
2218
|
return promise
|
|
1703
2219
|
}
|
|
1704
2220
|
|
|
2221
|
+
async rememberInvocationResult(record, result, { freshDispatch = false } = {}) {
|
|
2222
|
+
record.latest = result
|
|
2223
|
+
const delay = result?.retry_after_ms
|
|
2224
|
+
record.resumeNotBefore = result?.state === 'rejected_before_dispatch' && result.auto_retry_allowed === true
|
|
2225
|
+
&& Number.isSafeInteger(delay) && delay > 0 && delay <= 86_400_000
|
|
2226
|
+
? !freshDispatch && record.journalEntry?.resultHash === metadataHash(result) ? record.journalEntry.retryAt : this.recovery.now() + delay : 0
|
|
2227
|
+
if (record.journalEntry) {
|
|
2228
|
+
const next = { ...record.journalEntry, lastKnownState: result.state,
|
|
2229
|
+
retryAt: record.resumeNotBefore, resultHash: metadataHash(result) }
|
|
2230
|
+
await this.invocationJournal.update(record.journalSessionId, next, record.journalEntry)
|
|
2231
|
+
record.journalEntry = next
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1705
2235
|
async waitForInvocation(state, record, options = {}) {
|
|
1706
2236
|
const deadline = this.recovery.now() + this.recovery.maxWaitMilliseconds
|
|
1707
2237
|
let attempts = 0
|
|
1708
2238
|
let immediate = options.immediate === true || record.latest === undefined
|
|
1709
2239
|
|
|
1710
2240
|
while (!invocationIsTerminal(record.latest)) {
|
|
2241
|
+
if (options.signal?.aborted) throw abortError()
|
|
1711
2242
|
this.assertLiveTargetTurn(state, options.activeTurn)
|
|
1712
2243
|
const remaining = deadline - this.recovery.now()
|
|
1713
2244
|
if (remaining <= 0 || attempts >= this.recovery.maxAttempts) {
|
|
1714
2245
|
if (record.latest) {
|
|
1715
|
-
return timedOutInvocationResult(record.latest, record.invocationId, attempts)
|
|
2246
|
+
return timedOutInvocationResult(record.latest, record.invocationId, attempts, options.managed)
|
|
1716
2247
|
}
|
|
1717
|
-
|
|
2248
|
+
const failure = acceptedUnknownFailure(options.operation ?? 'resume', record.invocationId)
|
|
2249
|
+
if (options.managed) failure.feedback.next_action = 'inspect_original'
|
|
2250
|
+
throw failure
|
|
1718
2251
|
}
|
|
1719
2252
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2253
|
+
const rateWait = options.managed ? 0 : Math.max(0, (record.resumeNotBefore ?? 0) - this.recovery.now())
|
|
2254
|
+
if (rateWait >= remaining && record.latest) {
|
|
2255
|
+
const deferred = timedOutInvocationResult(record.latest, record.invocationId, attempts, options.managed)
|
|
2256
|
+
deferred.agent_client_wait.state = 'rate_limited'
|
|
2257
|
+
deferred.agent_client_wait.retry_after_ms = rateWait
|
|
2258
|
+
deferred.agent_client_wait.next_action = 'Wait for retry_after_ms, then resume this exact invocation_id. Do not call the business tool again or switch authorization.'
|
|
2259
|
+
return deferred
|
|
1725
2260
|
}
|
|
2261
|
+
const pause = Math.max(rateWait, immediate ? 0 : this.recovery.pollIntervalMilliseconds)
|
|
2262
|
+
if (pause > 0) await this.recovery.sleep(Math.min(pause, remaining), options.signal)
|
|
2263
|
+
if (options.signal?.aborted) throw abortError()
|
|
2264
|
+
this.assertLiveTargetTurn(state, options.activeTurn)
|
|
2265
|
+
if (this.recovery.now() >= deadline) continue
|
|
1726
2266
|
immediate = false
|
|
1727
2267
|
attempts += 1
|
|
1728
2268
|
|
|
1729
2269
|
try {
|
|
1730
2270
|
const transport = await this.getTransport()
|
|
1731
|
-
|
|
2271
|
+
if (!options.managed) {
|
|
2272
|
+
await this.sessionScopes.get(state.sessionId)
|
|
2273
|
+
await this.assertAuthorization(state)
|
|
2274
|
+
}
|
|
1732
2275
|
this.assertLiveTargetTurn(state, options.activeTurn)
|
|
1733
|
-
const result =
|
|
1734
|
-
await
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
)
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
if (result) record.latest = result
|
|
2276
|
+
const result = options.managed
|
|
2277
|
+
? (await this.inspectTrackedReceipt(state, record, options.signal)).result
|
|
2278
|
+
: invocationResult(await transport.resume(record.invocationId, {}, this.connectionOptions(state, options.signal)), record.invocationId, 'resume')
|
|
2279
|
+
if (result) await this.rememberInvocationResult(record, result, { freshDispatch: !options.managed })
|
|
2280
|
+
if (options.managed) {
|
|
2281
|
+
this.assertLiveTargetTurn(state, options.activeTurn)
|
|
2282
|
+
if (options.signal?.aborted) throw abortError()
|
|
2283
|
+
}
|
|
1742
2284
|
} catch (error) {
|
|
1743
|
-
if (
|
|
2285
|
+
if (isInvocationNotFound(error)) {
|
|
2286
|
+
// A late recovery response cannot replace cancellation or revive the
|
|
2287
|
+
// ended turn. Preserve the same guard previously reached by polling.
|
|
2288
|
+
try {
|
|
2289
|
+
if (options.signal?.aborted) throw abortError()
|
|
2290
|
+
this.assertLiveTargetTurn(state, options.activeTurn)
|
|
2291
|
+
} catch (cancelled) {
|
|
2292
|
+
throw publicFailure(options.managed ? 'inspect' : 'resume', cancelled, { invocationId: record.invocationId })
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
if (isInvocationNotFound(error) || !isAcceptedUnknown(error)) {
|
|
2296
|
+
const failure = publicFailure(options.managed ? 'inspect' : 'resume', error, { invocationId: record.invocationId })
|
|
2297
|
+
if (options.managed && ['transport_unavailable', 'unknown_failure', 'invocation_outcome_unknown'].includes(failure.feedback.category)) {
|
|
2298
|
+
failure.feedback.next_action = 'inspect_original'
|
|
2299
|
+
failure.feedback.retryable = false
|
|
2300
|
+
failure.feedback.original_outcome = 'unverified'
|
|
2301
|
+
}
|
|
2302
|
+
throw failure
|
|
2303
|
+
}
|
|
1744
2304
|
}
|
|
1745
2305
|
}
|
|
1746
2306
|
|
|
@@ -1756,16 +2316,39 @@ export class BailingHubAgentClientRuntime {
|
|
|
1756
2316
|
})
|
|
1757
2317
|
|
|
1758
2318
|
return this.runInvocationOperation(record, async () => {
|
|
1759
|
-
await this.assertAuthorization(state)
|
|
2319
|
+
if (!run.taskBinding) await this.assertAuthorization(state)
|
|
1760
2320
|
this.assertLiveTargetTurn(state, run.turn)
|
|
2321
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2322
|
+
await this.verifyInvocationJournal(state, invocationId, record)
|
|
2323
|
+
if (!run.taskBinding || record.invoked || invocationIsTerminal(record.latest)) {
|
|
2324
|
+
const taskBinding = await this.sessionTasks.binding(state.sessionId, { dispatch: true })
|
|
2325
|
+
if (metadataHash(taskBinding ?? null) !== metadataHash(run.taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
2326
|
+
}
|
|
1761
2327
|
if (invocationIsTerminal(record.latest)) return cloneJson(record.latest)
|
|
1762
2328
|
|
|
1763
2329
|
if (!record.invoked) {
|
|
1764
|
-
record.invoked = true
|
|
1765
2330
|
try {
|
|
1766
2331
|
const transport = await this.getTransport()
|
|
1767
2332
|
this.assertLiveTargetTurn(state, run.turn)
|
|
1768
|
-
|
|
2333
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2334
|
+
const reservation = await this.invocationJournal.reserve(state.sessionId,
|
|
2335
|
+
this.invocationMetadata(state, run, tool, clonedArguments, exec, invocationId))
|
|
2336
|
+
if (reservation.supported) {
|
|
2337
|
+
record.journalEntry = reservation.entry
|
|
2338
|
+
record.journalSessionId = state.sessionId
|
|
2339
|
+
record.resumeNotBefore = reservation.entry.retryAt
|
|
2340
|
+
}
|
|
2341
|
+
if (reservation.supported && !run.taskBinding) {
|
|
2342
|
+
await this.sessionScopes.get(state.sessionId)
|
|
2343
|
+
await this.sessionScopes.assertUsable(state.sessionId)
|
|
2344
|
+
}
|
|
2345
|
+
const currentTask = await this.sessionTasks.binding(state.sessionId, { dispatch: true })
|
|
2346
|
+
if (metadataHash(currentTask ?? null) !== metadataHash(run.taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
2347
|
+
this.sessionScopes.assertCurrent(state.sessionId)
|
|
2348
|
+
this.assertLiveTargetTurn(state, run.turn)
|
|
2349
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2350
|
+
record.invoked = true
|
|
2351
|
+
const result = reservation.created ? invocationResult(
|
|
1769
2352
|
await transport.invoke(
|
|
1770
2353
|
{
|
|
1771
2354
|
invocationId,
|
|
@@ -1777,12 +2360,13 @@ export class BailingHubAgentClientRuntime {
|
|
|
1777
2360
|
this.connectionOptions(state, exec.signal),
|
|
1778
2361
|
),
|
|
1779
2362
|
invocationId,
|
|
1780
|
-
)
|
|
1781
|
-
if (result) record
|
|
2363
|
+
) : undefined
|
|
2364
|
+
if (result) await this.rememberInvocationResult(record, result, { freshDispatch: true })
|
|
1782
2365
|
} catch (error) {
|
|
1783
2366
|
if (!isAcceptedUnknown(error)) {
|
|
1784
|
-
record.invoked
|
|
1785
|
-
|
|
2367
|
+
const failure = publicFailure('invoke', error, { invocationId, dispatch: record.invoked ? 'unknown' : 'not_dispatched' })
|
|
2368
|
+
if (failure.feedback.dispatch === 'not_dispatched') record.invoked = false
|
|
2369
|
+
throw failure
|
|
1786
2370
|
}
|
|
1787
2371
|
}
|
|
1788
2372
|
}
|
|
@@ -1791,32 +2375,76 @@ export class BailingHubAgentClientRuntime {
|
|
|
1791
2375
|
return this.waitForInvocation(state, record, {
|
|
1792
2376
|
immediate: record.latest === undefined,
|
|
1793
2377
|
operation: 'business-tool',
|
|
2378
|
+
managed: Boolean(run.taskBinding),
|
|
1794
2379
|
activeTurn: run.turn,
|
|
1795
2380
|
signal: exec.signal,
|
|
1796
2381
|
})
|
|
1797
|
-
})
|
|
2382
|
+
}, state.sessionId)
|
|
1798
2383
|
}
|
|
1799
2384
|
|
|
1800
2385
|
resumeTrackedInvocation(state, run, invocationId, signal) {
|
|
1801
2386
|
const record = this.invocationRecord(run, invocationId, { invoked: true })
|
|
1802
2387
|
const activeTurn = state.parentState?.currentRun?.turn
|
|
1803
2388
|
return this.runInvocationOperation(record, async () => {
|
|
1804
|
-
|
|
2389
|
+
if (!run.taskBinding) {
|
|
2390
|
+
await this.sessionScopes.get(state.sessionId)
|
|
2391
|
+
await this.assertAuthorization(state)
|
|
2392
|
+
}
|
|
1805
2393
|
this.assertLiveTargetTurn(state, activeTurn)
|
|
2394
|
+
if (signal?.aborted) throw abortError()
|
|
2395
|
+
await this.verifyInvocationJournal(state, invocationId, record)
|
|
2396
|
+
const taskBinding = await this.sessionTasks.binding(state.sessionId, { dispatch: true })
|
|
2397
|
+
if (metadataHash(taskBinding ?? null) !== metadataHash(run.taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
1806
2398
|
if (invocationIsTerminal(record.latest)) return cloneJson(record.latest)
|
|
2399
|
+
const receiptRecovery = Boolean(run.taskBinding) || this.hostReceiptBindings.has(`${state.sessionId}:${invocationId}`)
|
|
2400
|
+
if (receiptRecovery) {
|
|
2401
|
+
// A panel action may have completed this original invocation while this
|
|
2402
|
+
// model action waited for the shared lock. Never issue a second POST.
|
|
2403
|
+
const receipt = await this.inspectTrackedReceipt(state, record, signal)
|
|
2404
|
+
if (receipt.result) await this.rememberInvocationResult(record, receipt.result)
|
|
2405
|
+
this.assertLiveTargetTurn(state, activeTurn)
|
|
2406
|
+
if (signal?.aborted) throw abortError()
|
|
2407
|
+
if (invocationIsTerminal(record.latest)) return cloneJson(record.latest)
|
|
2408
|
+
if (receipt.dispatch_state !== 'not_dispatched' || receipt.journal_state !== 'absent' || !invocationNeedsResume(receipt.result)) {
|
|
2409
|
+
return this.waitForInvocation(state, record, { immediate: true, managed: true, operation: 'resume', activeTurn, signal })
|
|
2410
|
+
}
|
|
2411
|
+
const rateWait = Math.max(0, (record.resumeNotBefore ?? 0) - this.recovery.now())
|
|
2412
|
+
if (rateWait >= this.recovery.maxWaitMilliseconds && record.latest) return timedOutInvocationResult(record.latest, invocationId, 0, true)
|
|
2413
|
+
if (rateWait) {
|
|
2414
|
+
await this.recovery.sleep(rateWait, signal)
|
|
2415
|
+
// A wait separates dispatch from its proof; validate the whole group
|
|
2416
|
+
// again after the delay, never reuse a prior operation's snapshot.
|
|
2417
|
+
const afterWait = await this.sessionTasks.binding(state.sessionId, { dispatch: true })
|
|
2418
|
+
if (metadataHash(afterWait ?? null) !== metadataHash(run.taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
2419
|
+
await this.verifyInvocationJournal(state, invocationId, record)
|
|
2420
|
+
}
|
|
2421
|
+
this.assertLiveTargetTurn(state, activeTurn)
|
|
2422
|
+
if (signal?.aborted) throw abortError()
|
|
2423
|
+
const transport = await this.getTransport()
|
|
2424
|
+
this.sessionScopes.assertCurrent(state.sessionId)
|
|
2425
|
+
this.assertLiveTargetTurn(state, activeTurn)
|
|
2426
|
+
if (signal?.aborted) throw abortError()
|
|
2427
|
+
const result = invocationResult(await transport.resume(invocationId, {}, this.connectionOptions(state, signal)), invocationId, 'resume')
|
|
2428
|
+
if (result) await this.rememberInvocationResult(record, result, { freshDispatch: true })
|
|
2429
|
+
this.assertLiveTargetTurn(state, activeTurn)
|
|
2430
|
+
if (signal?.aborted) throw abortError()
|
|
2431
|
+
}
|
|
1807
2432
|
return this.waitForInvocation(state, record, {
|
|
1808
2433
|
immediate: true,
|
|
2434
|
+
managed: receiptRecovery,
|
|
1809
2435
|
operation: 'resume',
|
|
1810
2436
|
activeTurn,
|
|
1811
2437
|
signal,
|
|
1812
2438
|
})
|
|
1813
|
-
})
|
|
2439
|
+
}, state.sessionId)
|
|
1814
2440
|
}
|
|
1815
2441
|
|
|
1816
2442
|
assertLiveTargetTurn(state, turn) {
|
|
1817
|
-
const
|
|
1818
|
-
|
|
1819
|
-
|
|
2443
|
+
const owner = state.parentState ?? state
|
|
2444
|
+
// A frozen invocation may retain its original run, but cannot reactivate it.
|
|
2445
|
+
// Cross-turn recovery explicitly supplies the current active turn instead.
|
|
2446
|
+
if (owner.disposed || (turn !== undefined && owner.currentRun?.turn !== turn) ||
|
|
2447
|
+
owner.currentRun?.status !== 'active' || owner.endedTurns.has(owner.currentRun?.turn)) throw abortError()
|
|
1820
2448
|
}
|
|
1821
2449
|
|
|
1822
2450
|
definitionFor(state, run, tool) {
|
|
@@ -1829,7 +2457,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1829
2457
|
].join(', ')
|
|
1830
2458
|
return {
|
|
1831
2459
|
name: tool.name,
|
|
1832
|
-
description: `${tool.description}\nBailingHub governance metadata: ${governance}.`,
|
|
2460
|
+
description: `${tool.description}\nBailingHub governance metadata: ${governance}. Use this loaded tool directly when the target is clear. If rate limited before dispatch, wait for retry_after_ms then resume the original invocation_id. On uncertain outcomes recover the original invocation_id; never create a replacement call.`,
|
|
1833
2461
|
parameters: cloneJson(tool.parameters),
|
|
1834
2462
|
output: {
|
|
1835
2463
|
schema: {},
|
|
@@ -1844,13 +2472,19 @@ export class BailingHubAgentClientRuntime {
|
|
|
1844
2472
|
run.calls ??= new Map()
|
|
1845
2473
|
const signature = invocationSignature(state.authorization.connectionKey, tool.name, arguments_)
|
|
1846
2474
|
let binding = run.calls.get(exec.callId)
|
|
1847
|
-
if (binding && binding.signature !== signature) throw new Error('BailingHub invocation replay changed the original tool or arguments')
|
|
2475
|
+
if (binding && binding.signature !== signature) throw Object.assign(new Error('BailingHub invocation replay changed the original tool or arguments'), { publicCode: 'agent_invalid_request' })
|
|
1848
2476
|
if (!binding) {
|
|
1849
|
-
|
|
2477
|
+
if (!state.activeDefinitions.has(tool.name) || !run.activeTools.some((current) => declarationKey(current) === declarationKey(tool))) {
|
|
2478
|
+
throw Object.assign(new Error('This business tool is no longer loaded'), { feedback: describeFailure({ publicCode: 'tool_not_loaded' }, { operation: 'invoke', dispatch: 'not_dispatched' }) })
|
|
2479
|
+
}
|
|
2480
|
+
binding = { run: { ...run, invocations: run.invocations }, tool, signature, registeredName: tool.name }
|
|
1850
2481
|
run.calls.set(exec.callId, binding)
|
|
1851
2482
|
const id = digestId(state.sessionId, run.runId, exec.callId, tool.name)
|
|
2483
|
+
binding.invocationId = id
|
|
1852
2484
|
state.invocationBindings.set(id, binding)
|
|
1853
2485
|
}
|
|
2486
|
+
this.touchTool(state, tool.name)
|
|
2487
|
+
touchCatalogTool(run, tool.name)
|
|
1854
2488
|
return this.executeTrackedInvocation(state, binding.run, binding.tool, arguments_, exec)
|
|
1855
2489
|
},
|
|
1856
2490
|
}
|
|
@@ -1859,7 +2493,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1859
2493
|
searchDefinitionFor(state, run) {
|
|
1860
2494
|
return {
|
|
1861
2495
|
name: 'search_business_capabilities',
|
|
1862
|
-
description: 'Search within the authorized BailingHub workspace and
|
|
2496
|
+
description: 'Search within the authorized BailingHub workspace and add up to 12 relevant candidates to this turn\'s retained tools. This grants no new authority. ' + DISCOVERY_GUIDANCE,
|
|
1863
2497
|
parameters: {
|
|
1864
2498
|
type: 'object',
|
|
1865
2499
|
properties: {
|
|
@@ -1882,36 +2516,97 @@ export class BailingHubAgentClientRuntime {
|
|
|
1882
2516
|
if (!query || !Number.isInteger(limit) || limit < 1 || limit > MAX_ACTIVE_TOOLS) {
|
|
1883
2517
|
throw new TypeError('query is required and limit must be an integer from 1 to 12')
|
|
1884
2518
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
await
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2519
|
+
const search = async () => {
|
|
2520
|
+
this.assertRegistryTurn(state, run)
|
|
2521
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2522
|
+
try {
|
|
2523
|
+
const transport = await this.getTransport()
|
|
2524
|
+
await this.assertAuthorization(state)
|
|
2525
|
+
const result = normalizeCapabilitySearchResponse(
|
|
2526
|
+
await transport.searchCapabilities(
|
|
2527
|
+
{ query, limit, runId: run.runId },
|
|
2528
|
+
this.connectionOptions(state, exec.signal),
|
|
2529
|
+
),
|
|
2530
|
+
)
|
|
2531
|
+
this.assertRegistryTurn(state, run)
|
|
2532
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2533
|
+
await this.assertAuthorization(state)
|
|
2534
|
+
this.assertRegistryTurn(state, run)
|
|
2535
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2536
|
+
const update = mergeDiscoveredTools(run, result)
|
|
2537
|
+
await this.replaceActiveTools(state, run, result.tools.map(tool => tool.name))
|
|
2538
|
+
state.status = {
|
|
2539
|
+
...state.status,
|
|
2540
|
+
capabilityRevision: run.capabilityRevision,
|
|
2541
|
+
activeToolCount: run.activeTools.length,
|
|
2542
|
+
}
|
|
2543
|
+
return {
|
|
2544
|
+
schema: result.schema,
|
|
2545
|
+
discovery: result.discovery,
|
|
2546
|
+
...this.getSessionToolState(state.sessionId),
|
|
2547
|
+
...update,
|
|
2548
|
+
capability_revision: result.capabilityRevision,
|
|
2549
|
+
active_tools: run.activeTools.map((tool) => ({
|
|
2550
|
+
name: tool.name,
|
|
2551
|
+
scope: tool.scope,
|
|
2552
|
+
risk: tool.risk,
|
|
2553
|
+
approval_required: tool.approvalRequired,
|
|
2554
|
+
})),
|
|
2555
|
+
}
|
|
2556
|
+
} catch (error) {
|
|
2557
|
+
throw publicFailure('searchCapabilities', error)
|
|
1911
2558
|
}
|
|
1912
|
-
} catch (error) {
|
|
1913
|
-
throw publicFailure('searchCapabilities', error)
|
|
1914
2559
|
}
|
|
2560
|
+
const pending = (run.searchPromise ?? Promise.resolve()).then(search)
|
|
2561
|
+
run.searchPromise = pending.catch(() => {})
|
|
2562
|
+
return pending
|
|
2563
|
+
},
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
async inspectTrackedReceipt(state, record, signal) {
|
|
2568
|
+
if (!record.journalEntry) throw recoveryError('invocation_binding_unavailable', record.invocationId)
|
|
2569
|
+
await this.verifyInvocationJournal(state, record.invocationId, record)
|
|
2570
|
+
const taskBinding = await this.sessionTasks.binding(state.sessionId)
|
|
2571
|
+
if (metadataHash(taskBinding ?? null) !== metadataHash(record.journalEntry.taskBinding ?? null)) throw taskError('TASK_BINDING_CONFLICT')
|
|
2572
|
+
const transport = await this.getTransport()
|
|
2573
|
+
if (typeof transport.inspectInvocation !== 'function') throw taskError('TASK_UNSUPPORTED')
|
|
2574
|
+
return this.checkedInvocationReceipt(await transport.inspectInvocation(record.invocationId, this.connectionOptions(state, signal)), record.journalEntry)
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
checkedInvocationReceipt(receipt, original) {
|
|
2578
|
+
if (receipt?.schema_version !== 'bailing.agent-invocation-receipt.v1' || receipt.read_only !== true || receipt.business_operation_performed !== false ||
|
|
2579
|
+
receipt.invocation_id !== original.id || receipt.agent_run_id !== original.runId || receipt.route !== original.target.workspace || receipt.tool !== original.tool ||
|
|
2580
|
+
!['not_dispatched', 'attempted', 'unknown'].includes(receipt.dispatch_state) ||
|
|
2581
|
+
!['absent', 'dispatching', 'response_recorded', 'completed', 'uncertain', 'evidence_degraded', 'unknown'].includes(receipt.journal_state)) throw recoveryError('invocation_binding_conflict', original.id)
|
|
2582
|
+
if (receipt.result !== null) {
|
|
2583
|
+
receipt.result = invocationResult(receipt.result, original.id, 'inspect')
|
|
2584
|
+
if (!receipt.result || receipt.result.route !== original.target.workspace || receipt.result.tool !== original.tool) throw recoveryError('invocation_binding_conflict', original.id)
|
|
2585
|
+
}
|
|
2586
|
+
return receipt
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
inspectDefinitionFor(state, run) {
|
|
2590
|
+
return { name: 'inspect_governed_tool_invocation',
|
|
2591
|
+
description: 'Read the original governed invocation receipt without continuing execution, consuming approval or creating a run. Use only its original invocation_id. A missing or unavailable receipt does not prove the operation never ran. Task cancellation remains in force even when a late original result succeeds.',
|
|
2592
|
+
parameters: { type: 'object', properties: { invocation_id: { type: 'string' } }, required: ['invocation_id'], additionalProperties: false },
|
|
2593
|
+
output: { schema: {}, render: (_args, value) => [{ type: 'text', text: safeJson(value) }] },
|
|
2594
|
+
execute: async (value, exec) => {
|
|
2595
|
+
if (exec.agent !== state.agent || state.currentRun !== run || run.status !== 'active' || exec.signal?.aborted) throw abortError()
|
|
2596
|
+
if (!value || Object.keys(value).some(key => key !== 'invocation_id') || !INVOCATION_ID.test(value.invocation_id ?? '')) throw new TypeError('Only the original invocation_id is accepted')
|
|
2597
|
+
if (!state.taskManaged) await this.sessionScopes.get(state.sessionId)
|
|
2598
|
+
const binding = (state.authorizations ? state.authorizedInvocations : state.invocationBindings).get(value.invocation_id) ?? await this.recoverInvocationBinding(state, value.invocation_id)
|
|
2599
|
+
const record = binding.run.invocations.get(value.invocation_id)
|
|
2600
|
+
return this.serializeInvocation(state.sessionId, value.invocation_id, async () => {
|
|
2601
|
+
this.assertLiveTargetTurn(binding.state, run.turn)
|
|
2602
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2603
|
+
if (!binding.run.taskBinding) await this.assertAuthorization(binding.state)
|
|
2604
|
+
const receipt = await this.inspectTrackedReceipt(binding.state, record, exec.signal)
|
|
2605
|
+
if (receipt.result) await this.rememberInvocationResult(record, receipt.result)
|
|
2606
|
+
this.assertLiveTargetTurn(binding.state, run.turn)
|
|
2607
|
+
if (exec.signal?.aborted) throw abortError()
|
|
2608
|
+
return receipt
|
|
2609
|
+
})
|
|
1915
2610
|
},
|
|
1916
2611
|
}
|
|
1917
2612
|
}
|
|
@@ -1919,7 +2614,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1919
2614
|
resumeDefinitionFor(state, run) {
|
|
1920
2615
|
return {
|
|
1921
2616
|
name: 'resume_governed_tool_invocation',
|
|
1922
|
-
description: '
|
|
2617
|
+
description: 'Explicitly continue the exact governed BailingHub invocation after an uncertain outcome, pending approval, in-progress response, or pre-dispatch rate limit. Honor retry_after_ms before resuming; never switch authorization or create a replacement invocation. After reopening the same session, the host must retain its invocation store. Stored last-known states are not verified outcomes. Recovery can continue an approved original operation; it is not a read-only status check. If local recovery storage is unavailable, restore it before retrying. If invocation_not_found says inspect_original, stop automatic retries and verify the original execution evidence; it does not prove the business action never ran.',
|
|
1923
2618
|
parameters: {
|
|
1924
2619
|
type: 'object',
|
|
1925
2620
|
properties: {
|
|
@@ -1944,8 +2639,9 @@ export class BailingHubAgentClientRuntime {
|
|
|
1944
2639
|
throw new TypeError('invocation_id must be a 64-character lowercase digest')
|
|
1945
2640
|
}
|
|
1946
2641
|
if (Object.keys(arguments_).some((key) => key !== 'invocation_id')) throw new TypeError('Only the original invocation_id may be supplied for recovery')
|
|
1947
|
-
const binding = state.invocationBindings.get(invocationId)
|
|
1948
|
-
|
|
2642
|
+
const binding = state.invocationBindings.get(invocationId) ?? await this.recoverInvocationBinding(state, invocationId)
|
|
2643
|
+
run.recoveries ??= new Map()
|
|
2644
|
+
run.recoveries.set(invocationId, binding.run.invocations.get(invocationId))
|
|
1949
2645
|
return this.resumeTrackedInvocation(state, binding.run, invocationId, exec.signal)
|
|
1950
2646
|
},
|
|
1951
2647
|
}
|
|
@@ -1975,12 +2671,14 @@ export class BailingHubAgentClientRuntime {
|
|
|
1975
2671
|
sections = replaceNamed(sections, {
|
|
1976
2672
|
name: 'bailinghub:agent-client-profile', order: 20,
|
|
1977
2673
|
text: [
|
|
1978
|
-
run.crossSystem
|
|
2674
|
+
run.sessionTools ? SESSION_TOOL_GUIDANCE : run.crossSystem
|
|
1979
2675
|
? 'BailingHub cross-system targets are selected. First choose the intended authorization_ref and call search_business_capabilities with a minimal target-specific task. Only that target receives the task and loads its context; no target is loaded automatically. Tool names are scoped aliases; use the tool only with one of its enumerated authorizations.'
|
|
1980
2676
|
: 'BailingHub same-system authorizations are active. Shared tools require an authorization_ref on every call.',
|
|
1981
2677
|
'Choose only the account(s) intended by the user. Never infer an account from list order or the default connection.',
|
|
1982
2678
|
SYSTEM_DESCRIPTION_GUIDANCE,
|
|
1983
2679
|
SUBJECT_DISPLAY_GUIDANCE,
|
|
2680
|
+
...(run.sessionTools ? [] : [DISCOVERY_GUIDANCE]),
|
|
2681
|
+
`Current tool set: ${safeJson(this.getSessionToolState(state.sessionId))}`,
|
|
1984
2682
|
'All declared account rules remain scoped to their authorization. Do not mix records, identifiers, memory or knowledge between accounts. Only transfer data when the user explicitly requests that permitted action.',
|
|
1985
2683
|
...(run.crossSystem ? [
|
|
1986
2684
|
'Plan each step with its system, account and prerequisites. Check each result before running dependent steps; report completed, awaiting-approval, failed and not-started separately. A selected account is not permission to publish or bypass approval.',
|
|
@@ -1989,7 +2687,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
1989
2687
|
] : []),
|
|
1990
2688
|
`Authorization directory: ${safeJson(directory)}`,
|
|
1991
2689
|
`Conflicting tool declarations (unavailable): ${safeJson(run.shared?.conflicts ?? [])}`,
|
|
1992
|
-
`
|
|
2690
|
+
`Known tools omitted by the retained registry budget: ${run.shared?.omitted ?? 0}`,
|
|
1993
2691
|
'Recover a pending call only with its original invocation_id. Never repeat a write with another authorization or a new invocation id.',
|
|
1994
2692
|
...profiles,
|
|
1995
2693
|
].join('\n\n'),
|
|
@@ -2004,12 +2702,14 @@ export class BailingHubAgentClientRuntime {
|
|
|
2004
2702
|
}
|
|
2005
2703
|
}
|
|
2006
2704
|
} else {
|
|
2007
|
-
|
|
2705
|
+
const profile = profileSection(state, run)
|
|
2706
|
+
sections = replaceNamed(sections, { ...profile, text: profile.text + `\nCurrent tool set: ${safeJson(this.getSessionToolState(state.sessionId))}` })
|
|
2008
2707
|
for (const entry of contextEntries(run)) contexts = replaceNamed(contexts, entry)
|
|
2009
2708
|
}
|
|
2010
2709
|
|
|
2011
2710
|
const byName = new Map(tools.map((tool) => [tool.name, tool]))
|
|
2012
2711
|
for (const definition of state.activeDefinitions.values()) {
|
|
2712
|
+
if (!RESERVED_TOOLS.has(definition.name) && !state.visibleToolNames.includes(definition.name)) continue
|
|
2013
2713
|
byName.set(definition.name, toolSchema(definition))
|
|
2014
2714
|
}
|
|
2015
2715
|
tools = [...byName.values()]
|
|
@@ -2486,8 +3186,11 @@ export class BailingHubAgentClientRuntime {
|
|
|
2486
3186
|
),
|
|
2487
3187
|
)
|
|
2488
3188
|
} catch (error) {
|
|
3189
|
+
if (isInvocationNotFound(error)) {
|
|
3190
|
+
throw publicFailure('resume', options.signal?.aborted ? abortError() : error, { invocationId: id })
|
|
3191
|
+
}
|
|
2489
3192
|
if (isAcceptedUnknown(error)) throw acceptedUnknownFailure('resume', id)
|
|
2490
|
-
throw publicFailure('resume', error)
|
|
3193
|
+
throw publicFailure('resume', error, { invocationId: id })
|
|
2491
3194
|
}
|
|
2492
3195
|
}
|
|
2493
3196
|
|
|
@@ -2513,6 +3216,95 @@ export class BailingHubAgentClientRuntime {
|
|
|
2513
3216
|
}
|
|
2514
3217
|
}
|
|
2515
3218
|
|
|
3219
|
+
// Read-only host seam: it neither discovers tools nor validates over HTTP.
|
|
3220
|
+
getSessionToolState(sessionId) {
|
|
3221
|
+
const state = this.statesBySessionId.get(String(sessionId))
|
|
3222
|
+
const run = state?.currentRun
|
|
3223
|
+
let availability = !state ? 'unknown' : state.disposed || run?.status !== 'active' ? 'inactive' : 'active'
|
|
3224
|
+
if (availability === 'active') {
|
|
3225
|
+
try { this.sessionScopes.assertCurrent(state.sessionId) }
|
|
3226
|
+
catch { availability = 'blocked' }
|
|
3227
|
+
}
|
|
3228
|
+
const names = availability === 'active'
|
|
3229
|
+
? [...state.activeDefinitions.keys()].filter((name) => !RESERVED_TOOLS.has(name)) : []
|
|
3230
|
+
const sessionView = state?.sessionToolCache ? sessionToolView(state, run, availability === 'active') : undefined
|
|
3231
|
+
const activeTools = sessionView ? sessionView.active_tools : run?.multi ? (run.shared?.tools ?? []).filter((entry) => names.includes(entry.name)).map((entry) => ({
|
|
3232
|
+
name: entry.name, authorization_refs: entry.targets.map((target) => target.authorization.ref),
|
|
3233
|
+
...(run.crossSystem ? { original_name: entry.tool.name, system_ref: entry.targets[0].authorization.systemRef } : {}),
|
|
3234
|
+
})) : names.map((name) => ({ name }))
|
|
3235
|
+
const catalogConflictCount = run?.multi
|
|
3236
|
+
? run.targets.reduce((sum, entry) => sum + (entry.run?.catalogConflicts?.size ?? 0), 0)
|
|
3237
|
+
: run?.catalogConflicts?.size ?? 0
|
|
3238
|
+
return {
|
|
3239
|
+
state: availability,
|
|
3240
|
+
...(this.sessionTasks.entry(String(sessionId)).record || this.sessionTasks.entry(String(sessionId)).failure ? { task: this.sessionTaskView(String(sessionId)) } : {}),
|
|
3241
|
+
toolset: {
|
|
3242
|
+
update: 'merge', scope: 'dsh_session', lifetime: 'active_turn',
|
|
3243
|
+
...(sessionView ? { reuse: 'session_runtime', cached_count: sessionView.cached_tools.length } : {}),
|
|
3244
|
+
generation: state?.toolsetGeneration ?? 0, active_count: activeTools.length, limit: RETAINED_TOOL_LIMIT,
|
|
3245
|
+
visible_count: activeTools.filter(tool => state?.visibleToolNames.includes(tool.name)).length, visible_limit: VISIBLE_TOOL_LIMIT,
|
|
3246
|
+
retained_outside_window_count: activeTools.filter(tool => !state?.visibleToolNames.includes(tool.name)).length,
|
|
3247
|
+
omitted_tool_count: run?.shared?.omitted ?? 0,
|
|
3248
|
+
conflicting_tool_count: (run?.shared?.conflicts.length ?? 0) + catalogConflictCount,
|
|
3249
|
+
catalog_conflict_count: catalogConflictCount,
|
|
3250
|
+
},
|
|
3251
|
+
active_tools: activeTools,
|
|
3252
|
+
visible_tools: activeTools.filter(tool => state?.visibleToolNames.includes(tool.name)),
|
|
3253
|
+
...(sessionView ? { cached_tools: sessionView.cached_tools, targets: sessionView.targets,
|
|
3254
|
+
cache: { lifetime: sessionView.cache.lifetime, target_count: sessionView.cache.targetCount,
|
|
3255
|
+
tool_count: sessionView.cache.toolCount, bytes: sessionView.cache.bytes,
|
|
3256
|
+
limit: sessionView.cache.maxTools, byte_limit: sessionView.cache.maxBytes } } : {}),
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
// A custom host may call this only after its registry reports UNKNOWN_TOOL.
|
|
3261
|
+
// Ownership and retirement are checked locally; arbitrary names are untouched.
|
|
3262
|
+
getToolDispatchFeedback(sessionId, { toolName, callId, errorCode } = {}) {
|
|
3263
|
+
if (errorCode !== 'UNKNOWN_TOOL') return null
|
|
3264
|
+
const state = this.statesBySessionId.get(String(sessionId))
|
|
3265
|
+
if (!state || RESERVED_TOOLS.has(toolName) || !state.knownToolNames.has(toolName) || state.activeDefinitions.has(toolName)) return null
|
|
3266
|
+
if (state.agent.ctx.tools.get?.(toolName, state.agent) !== undefined) return null
|
|
3267
|
+
const view = this.getSessionToolState(sessionId)
|
|
3268
|
+
if (view.state !== 'active') return describeFailure(
|
|
3269
|
+
view.state === 'blocked' ? { code: 'SESSION_SCOPE_UNAVAILABLE' } : Object.assign(new Error(), { name: 'AbortError' }),
|
|
3270
|
+
{ operation: 'tool_dispatch', origin: 'host', dispatch: 'not_dispatched' },
|
|
3271
|
+
)
|
|
3272
|
+
return this.originalCallFeedback(state.currentRun, toolName, callId) ??
|
|
3273
|
+
describeFailure({ code: 'tool_not_loaded' }, { operation: 'tool_dispatch', origin: 'host', dispatch: 'not_dispatched' })
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
originalCallFeedback(run, toolName, callId) {
|
|
3277
|
+
const binding = typeof callId === 'string' ? run?.calls?.get(callId) : undefined
|
|
3278
|
+
if (binding?.registeredName !== toolName || !binding.invocationId) return null
|
|
3279
|
+
const record = binding.run.invocations.get(binding.invocationId)
|
|
3280
|
+
const feedback = describeFailure({ code: 'tool_not_loaded' }, {
|
|
3281
|
+
operation: 'tool_dispatch', origin: 'host', invocationId: binding.invocationId,
|
|
3282
|
+
dispatch: record?.invoked === false ? 'not_dispatched' : record?.invoked === true ? 'attempted' : 'unknown',
|
|
3283
|
+
})
|
|
3284
|
+
feedback.next_action = record?.invoked === false && !record.latest ? 'rediscover'
|
|
3285
|
+
: record?.invoked === true && (!record.latest || invocationNeedsResume(record.latest)) ? 'resume_original' : 'inspect_original'
|
|
3286
|
+
feedback.retryable = ['resume_original', 'rediscover'].includes(feedback.next_action)
|
|
3287
|
+
return feedback
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
async onToolExecute(exec, next) {
|
|
3291
|
+
const state = exec.agent && this.statesByAgent.get(exec.agent)
|
|
3292
|
+
const result = await next()
|
|
3293
|
+
if (!state || !result?.isError) return result
|
|
3294
|
+
const captured = this.toolFailures.get(exec)
|
|
3295
|
+
this.toolFailures.delete(exec)
|
|
3296
|
+
const feedback = captured ?? this.getToolDispatchFeedback(state.sessionId, {
|
|
3297
|
+
toolName: exec.name, callId: exec.callId, errorCode: result.error?.info?.code,
|
|
3298
|
+
})
|
|
3299
|
+
if (!feedback) return result
|
|
3300
|
+
return {
|
|
3301
|
+
...result,
|
|
3302
|
+
error: { ...result.error, message: feedback.message },
|
|
3303
|
+
meta: { ...result.meta, bailinghub: { feedback } },
|
|
3304
|
+
content: [{ type: 'text', text: safeJson({ feedback }) }],
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
2516
3308
|
inspectSession(sessionId) {
|
|
2517
3309
|
const state = this.statesBySessionId.get(String(sessionId))
|
|
2518
3310
|
return state ? sanitizePublicValue(state.status) : { state: 'unknown' }
|
|
@@ -2520,8 +3312,13 @@ export class BailingHubAgentClientRuntime {
|
|
|
2520
3312
|
|
|
2521
3313
|
async clearActiveTools(state) {
|
|
2522
3314
|
const disposers = [...state.activeDisposers.values()]
|
|
3315
|
+
if (state.activeDefinitions.size) state.toolsetGeneration += 1
|
|
2523
3316
|
state.activeDisposers.clear()
|
|
2524
3317
|
state.activeDefinitions.clear()
|
|
3318
|
+
state.definitionSlots.clear()
|
|
3319
|
+
state.visibleToolNames = []
|
|
3320
|
+
if (!state.sessionToolCache) state.toolUsage.clear()
|
|
3321
|
+
state.status = { ...state.status, activeToolCount: 0 }
|
|
2525
3322
|
const pending = []
|
|
2526
3323
|
for (const dispose of disposers) {
|
|
2527
3324
|
try {
|
|
@@ -2540,6 +3337,9 @@ export class BailingHubAgentClientRuntime {
|
|
|
2540
3337
|
}
|
|
2541
3338
|
|
|
2542
3339
|
async disposeSession(sessionId) {
|
|
3340
|
+
this.hostActionControllers.get(String(sessionId))?.abort()
|
|
3341
|
+
this.hostActionControllers.delete(String(sessionId))
|
|
3342
|
+
for (const key of this.hostReceiptBindings) if (key.startsWith(`${sessionId}:`)) this.hostReceiptBindings.delete(key)
|
|
2543
3343
|
this.observedSessions.delete(String(sessionId))
|
|
2544
3344
|
this.observedConversationStarts.delete(String(sessionId))
|
|
2545
3345
|
const state = this.statesBySessionId.get(String(sessionId))
|
|
@@ -2550,6 +3350,7 @@ export class BailingHubAgentClientRuntime {
|
|
|
2550
3350
|
async disposeState(state) {
|
|
2551
3351
|
if (state.disposed) return
|
|
2552
3352
|
state.disposed = true
|
|
3353
|
+
state.sessionToolCache?.clear()
|
|
2553
3354
|
for (const controller of state.systemInfoLoads.values()) controller.abort()
|
|
2554
3355
|
for (const run of state.runs.values()) run.abortController?.abort()
|
|
2555
3356
|
await this.clearActiveTools(state)
|
|
@@ -2562,6 +3363,10 @@ export class BailingHubAgentClientRuntime {
|
|
|
2562
3363
|
}
|
|
2563
3364
|
|
|
2564
3365
|
async dispose() {
|
|
3366
|
+
this.disposed = true
|
|
3367
|
+
for (const controller of this.hostActionControllers.values()) controller.abort()
|
|
3368
|
+
this.hostActionControllers.clear()
|
|
3369
|
+
this.hostReceiptBindings.clear()
|
|
2565
3370
|
this.observedSessions.clear()
|
|
2566
3371
|
this.observedConversationStarts.clear()
|
|
2567
3372
|
await Promise.allSettled([...this.states].map((state) => this.disposeState(state)))
|