dsh-ptc-plus 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -2
- package/README.md +4 -2
- package/client.cjs +3392 -2811
- package/compiler-core.cjs +127 -127
- package/compiler-core.cjs.map +2 -2
- package/docs/README.md +1 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
- package/docs/adr/0023-global-user-bindings.md +12 -0
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
- package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
- package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
- package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
- package/docs/adr/README.md +7 -1
- package/docs/architecture.md +1 -1
- package/docs/capability-planning-components.md +6 -6
- package/docs/capability-projection.md +1 -1
- package/docs/capability-surface.md +133 -0
- package/docs/client-ui.md +17 -15
- package/docs/client-visual-contract.md +7 -7
- package/docs/durability-design.md +9 -2
- package/docs/installation.md +17 -10
- package/docs/panel-concept.md +171 -0
- package/docs/releases/v0.5.2.md +26 -14
- package/docs/releases/v0.5.3.md +77 -0
- package/docs/releases/v0.5.4.md +75 -0
- package/docs/runtime-reference.md +21 -1
- package/docs/user-bindings.en.md +15 -0
- package/docs/user-bindings.md +15 -0
- package/docs/v0.5.3-goals.md +117 -0
- package/docs/verification.md +4 -4
- package/index.js +176 -41
- package/internal/binding-adoption.js +228 -0
- package/internal/capability-command-receipt.js +106 -8
- package/internal/capability-environment.js +1109 -312
- package/internal/capability-plan.js +18 -2
- package/internal/capability-policy.js +13 -0
- package/internal/capability-selection.js +330 -0
- package/internal/config-spec.js +14 -5
- package/internal/cordis-tools-owner.js +2 -1
- package/internal/direct-surface-owner.js +44 -17
- package/internal/edit-transport-owner.js +6 -5
- package/internal/execution-provider-owner.js +6 -0
- package/internal/execution-seam-compat.js +25 -0
- package/internal/host-tool-sdk.js +308 -0
- package/internal/message-sources.js +21 -0
- package/internal/official-repl-priority.js +31 -10
- package/internal/program-bindings.js +25 -5
- package/internal/repl-memory-projection.js +21 -2
- package/internal/runtime-bridge-owner.js +118 -9
- package/internal/runtime-config.js +10 -0
- package/internal/runtime-messages.js +5 -0
- package/internal/session-journal-recovery.js +210 -32
- package/internal/session-journal-schema.js +3 -0
- package/internal/session-journal.js +46 -9
- package/internal/session-log-view.js +69 -7
- package/internal/session-runtime.js +106 -39
- package/internal/settings-compat.js +6 -0
- package/internal/user-bindings-owner.js +126 -6
- package/package.json +59 -37
- package/src/client-authoring-view.js +7 -8
- package/src/client-capabilities-view.js +874 -909
- package/src/client-capability-sort.js +22 -9
- package/src/client-copy.js +118 -272
- package/src/client-indicator-view.js +2 -1
- package/src/client-repl-view.js +35 -16
- package/src/client-settings-view.js +90 -33
- package/src/client-styles.js +67 -19
- package/src/client.js +49 -22
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { capabilityRule, capabilityPolicyForConfig, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from './capability-policy.js'
|
|
1
|
+
import { capabilityRule, capabilityPolicyForConfig, capabilityPolicyFromRecord, changeCapabilityRule, effectiveCapabilitySettings, CAPABILITY_DISCLOSURES, CAPABILITY_INVENTORY_CATEGORIES } from './capability-policy.js'
|
|
2
2
|
/** One session-owned capability view for presentation, discovery and preparation. */
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
|
-
import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from '
|
|
4
|
+
import { jsonSchemaToTs, renderToolsSdk, ToolNotFoundError } from './host-tool-sdk.js'
|
|
5
5
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
6
|
+
// Namespace import, not a named one: an older host does not export the plugin-record
|
|
7
|
+
// writer at all, and a missing named export would fail this module's load instead of
|
|
8
|
+
// leaving the fallback carrier to the branch that checks for it.
|
|
9
|
+
import * as dshSession from '@deepseek-ai/dsh-session'
|
|
6
10
|
import { isModelInvocable, isUserInvocable } from '@deepseek-ai/dsh-skill'
|
|
7
11
|
import { createScope } from '@deepseek-ai/dsh-scope'
|
|
8
12
|
import { Context } from '@deepseek-ai/cordis'
|
|
@@ -10,14 +14,17 @@ import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
|
|
10
14
|
import { readSessionLog } from './session-events.js'
|
|
11
15
|
import { readCapabilityCommandReceipt } from './capability-command-receipt.js'
|
|
12
16
|
import { createCapabilityPlan } from './capability-plan.js'
|
|
13
|
-
import {
|
|
17
|
+
import { bindingAdoptionOf } from './binding-adoption.js'
|
|
18
|
+
import { bindingModelPreferences } from './user-binding-model-context.js'
|
|
19
|
+
import { CAPABILITY_ENTRY_ID, capabilityChoiceOf, capabilityRuleOfSelection, capabilitySelectionOf, CAPABILITY_SELECTION_RECORD, CAPABILITY_SELECTION_SCHEMA } from './capability-selection.js'
|
|
20
|
+
import { CAPABILITY_PLAN_FORM, isPtcMessageSource, PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
|
|
14
21
|
import { deepFreeze } from './record-utils.js'
|
|
15
22
|
import { capabilityProfileConfiguration } from './runtime-config.js'
|
|
16
|
-
import { readRuntimeAvailability } from './runtime-messages.js'
|
|
23
|
+
import { readRuntimeAvailability, readRuntimeMessage } from './runtime-messages.js'
|
|
17
24
|
import { PLAN_CATEGORY_LINES, PLAN_NAVIGATION, PLAN_OVERAGE, PLAN_OVERAGE_HOLE,
|
|
18
25
|
PROMPT_SLOT_IDS, planCategorySlotId, resolvePromptText, resolvePromptTextConfig } from './prompt-text.js'
|
|
19
26
|
|
|
20
|
-
export
|
|
27
|
+
export { CAPABILITY_PLAN_FORM }
|
|
21
28
|
export const CAPABILITY_EVIDENCE_KEY = 'dshPtcPlusCapabilities'
|
|
22
29
|
const EVIDENCE_KEY = CAPABILITY_EVIDENCE_KEY
|
|
23
30
|
const DIRECT_TRANSPORT = 'run_code'
|
|
@@ -137,14 +144,15 @@ function sourcePlan(message, id) {
|
|
|
137
144
|
const snapshot = source.snapshot
|
|
138
145
|
const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3',
|
|
139
146
|
'ptc-capability-environment/v4', 'ptc-capability-environment/v5'].indexOf(snapshot?.schema)
|
|
140
|
-
//
|
|
141
|
-
// shape
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// settings and therefore a v3 plan, or a closed
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
// A historical generation planned with exactly one planner generation for the settings
|
|
148
|
+
// shape its schema records: a context generation (v4) always plans v3, a policy
|
|
149
|
+
// generation (v3) plans v2, and v1/v2 planned v1. Generation 5 records the plan words
|
|
150
|
+
// instead, and its plan schema follows from the recorded settings: the recomputation
|
|
151
|
+
// below proves the whole plan, which makes a separate schema guess both unnecessary and
|
|
152
|
+
// wrong (a v5 snapshot may carry context settings and therefore a v3 plan, or a closed
|
|
153
|
+
// plan and therefore a v2 one).
|
|
154
|
+
const expectedPlanSchema = generation >= 3 ? 'ptc-capability-plan/v3'
|
|
155
|
+
: generation === 2 ? 'ptc-capability-plan/v2' : 'ptc-capability-plan/v1'
|
|
148
156
|
if (source.digest !== digest(snapshot) || generation < 0
|
|
149
157
|
|| !Array.isArray(snapshot.capabilities)
|
|
150
158
|
|| (generation < 4 && snapshot.plan?.schema !== expectedPlanSchema)) {
|
|
@@ -312,16 +320,97 @@ function sectionDependencies(sections) {
|
|
|
312
320
|
.map(section => ({ id: section.name, text: section.text }))
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
/**
|
|
324
|
+
* One section with the servers no program of this step may name removed from its directory.
|
|
325
|
+
*
|
|
326
|
+
* The directory lists the servers a program may name as the `server` argument, so a server
|
|
327
|
+
* this session closed leaves the list, and so does every server when the step delivers no
|
|
328
|
+
* member that takes one (`nameable`): the names are the argument of the three resource
|
|
329
|
+
* operations and of nothing else. A directory this build cannot read is left exactly as the
|
|
330
|
+
* Host wrote it: an unreadable declaration is no evidence about a closure.
|
|
331
|
+
*/
|
|
332
|
+
function recordKeptServers(decisions, section, nameable = true) {
|
|
333
|
+
if (section.name !== 'mcp-resource-servers') return [section]
|
|
334
|
+
if (!nameable) return []
|
|
335
|
+
if (decisions.servers.size === 0) return [section]
|
|
336
|
+
let servers
|
|
337
|
+
try { servers = resourceServers([section]) } catch { return [section] }
|
|
338
|
+
const kept = servers.filter(server => !decisions.servers.has(server))
|
|
339
|
+
if (kept.length === servers.length) return [section]
|
|
340
|
+
return kept.length === 0 ? []
|
|
341
|
+
: [{ ...section, text: `Use the following MCP resource servers as the server argument: ${JSON.stringify(kept)}.` }]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The three Host resource operations a server row can name, in the Host's own order. */
|
|
345
|
+
const RESOURCE_OPERATIONS = Object.freeze(['list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'])
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The invocation sentence one resource server row states for the operations it still has.
|
|
349
|
+
*
|
|
350
|
+
* A saved policy states the operations it delivers. The legacy layer states the whole path in
|
|
351
|
+
* one sentence while every operation is available and states the remaining ones otherwise, so
|
|
352
|
+
* an unchanged legacy session reads the bytes it always read and a closure still withdraws the
|
|
353
|
+
* call it took away. A row may never instruct a call this session closed.
|
|
354
|
+
*/
|
|
355
|
+
function resourceOperationNotice(operations, policyLayer) {
|
|
356
|
+
if (!policyLayer && operations.length === RESOURCE_OPERATIONS.length) {
|
|
357
|
+
return 'Use tools.list_mcp_resources({server}), tools.list_mcp_resource_templates({server}), and tools.read_mcp_resource({server,uri}); use the listed URI or expand a listed template.'
|
|
358
|
+
}
|
|
359
|
+
return operations.length ? 'Available operations: ' + operations.map(name => `tools.${name}(${name === 'read_mcp_resource' ? '{server,uri}' : '{server}'})`).join(', ') + '.'
|
|
360
|
+
: 'No resource operation is available in this session.'
|
|
361
|
+
}
|
|
362
|
+
|
|
315
363
|
function requiresCapabilityInventory(settings) {
|
|
316
364
|
settings = effectiveCapabilitySettings(settings)
|
|
317
365
|
return settings.enabled || settings.policy && (settings.statistics?.enabled === true || settings.recoveryEnabled)
|
|
318
366
|
}
|
|
319
367
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
368
|
+
/**
|
|
369
|
+
* The one-selection spelling of a recorded choice, or `undefined` when the
|
|
370
|
+
* record states a dimension that spelling cannot carry.
|
|
371
|
+
*
|
|
372
|
+
* A record states dimensions, a saved decision states one selection, and the two
|
|
373
|
+
* vocabularies meet exactly at the shapes `capabilityRuleOfSelection` produces
|
|
374
|
+
* for a whole selection. Anything narrower (a single dimension) stays a dimension
|
|
375
|
+
* rule, because writing it as a selection would also decide the other dimension.
|
|
376
|
+
*/
|
|
377
|
+
function compactSelection(choice) {
|
|
378
|
+
const dimensions = Object.keys(choice).sort().join(',')
|
|
379
|
+
return dimensions === 'availability' && choice.availability === 'closed' ? 'closed'
|
|
380
|
+
: dimensions === 'availability,disclosure' && choice.availability === 'enabled'
|
|
381
|
+
&& ['full', 'on-demand'].includes(choice.disclosure) ? choice.disclosure
|
|
382
|
+
: dimensions === 'availability,disclosure' && choice.availability === 'automatic'
|
|
383
|
+
&& choice.disclosure === 'automatic' ? 'automatic' : undefined
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* This session's recorded choices, in the vocabulary a composition reads.
|
|
388
|
+
*
|
|
389
|
+
* The record is the owner: a composition that needs this session's choices takes
|
|
390
|
+
* them from here and from nowhere else. `state.sessionOverrides` and a previous
|
|
391
|
+
* composition are not inputs — they are what an earlier composition made of this
|
|
392
|
+
* same record, so reading them back would let a stale composition outvote a
|
|
393
|
+
* decision the log already holds.
|
|
394
|
+
*/
|
|
395
|
+
function sessionOverridesOf(statement) {
|
|
396
|
+
if (statement?.state !== 'decided') return {}
|
|
397
|
+
const overrides = {}
|
|
398
|
+
for (const [id, choice] of Object.entries(statement.selection.choices)) {
|
|
399
|
+
overrides[id] = compactSelection(choice) ?? { ...choice }
|
|
400
|
+
}
|
|
401
|
+
return overrides
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Two override maps state the same choices when they state the same decisions. */
|
|
405
|
+
function sameChoices(left, right) {
|
|
406
|
+
// One decision has two spellings: the whole selection a saved configuration states and
|
|
407
|
+
// the dimensions a record or a policy rule states. A plan delivered by an earlier
|
|
408
|
+
// release may hold either, so the comparison asks what each map decides — through the
|
|
409
|
+
// one mapping that owns that equivalence — rather than how this build spells it.
|
|
410
|
+
const canonical = overrides => JSON.stringify(Object.keys(overrides ?? {}).sort().map(id =>
|
|
411
|
+
[id, Object.entries(capabilityRuleOfSelection(overrides[id]))
|
|
412
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)]))
|
|
413
|
+
return canonical(left) === canonical(right)
|
|
325
414
|
}
|
|
326
415
|
|
|
327
416
|
function savedConfiguration(settings) {
|
|
@@ -346,6 +435,21 @@ function sdkMemberCall(name, callArguments) {
|
|
|
346
435
|
: `tools[${JSON.stringify(name)}](${callArguments})`
|
|
347
436
|
}
|
|
348
437
|
|
|
438
|
+
/**
|
|
439
|
+
* One managed `tools:sdk` render: the members this step declares plus the edit
|
|
440
|
+
* transport, produced by the Host's own renderer so the bytes are the Host's.
|
|
441
|
+
*
|
|
442
|
+
* The direct transport is never a member: it is how a program is called, not
|
|
443
|
+
* something the model calls against. The edit transport is callable inside every
|
|
444
|
+
* program and belongs to no disclosure decision, so it is declared whenever the
|
|
445
|
+
* Host declares it at all.
|
|
446
|
+
*/
|
|
447
|
+
function renderManagedSdk(candidates, members) {
|
|
448
|
+
const declared = members.filter(schema => !TRANSPORTS.has(schema.name))
|
|
449
|
+
const editTransport = candidates.find(schema => schema.name === EDIT_TRANSPORT)
|
|
450
|
+
return renderToolsSdk(editTransport === undefined ? declared : [...declared, editTransport])
|
|
451
|
+
}
|
|
452
|
+
|
|
349
453
|
function toolCapability(schema, sections) {
|
|
350
454
|
return { id: `tools.${schema.name}`, category: CAPABILITY_INVENTORY_CATEGORIES.tool,
|
|
351
455
|
revision: digest(schema), schema,
|
|
@@ -432,14 +536,30 @@ function readContractUpdate(message, planDigest, sessionId) {
|
|
|
432
536
|
return snapshot
|
|
433
537
|
}
|
|
434
538
|
|
|
435
|
-
/**
|
|
436
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Derived metadata only: invocation continues through the request's original host functions.
|
|
541
|
+
*
|
|
542
|
+
* `readUserBindings` is the plugin's own store read (`list()`), supplied because
|
|
543
|
+
* that store belongs to the user-bindings owner: the capability read reports what
|
|
544
|
+
* the global catalog supplies without owning or duplicating it.
|
|
545
|
+
*/
|
|
546
|
+
export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent, store, sessionHistoryForAgent, readUserBindings, appendRecord: hostAppendRecord }) {
|
|
437
547
|
let currentConfig = config
|
|
438
548
|
const sessions = new Map()
|
|
439
549
|
const histories = new WeakMap()
|
|
440
|
-
|
|
550
|
+
// The host's own writer for a record outside the model-visible surface. Every supported
|
|
551
|
+
// release publishes it, so a session states a decision when it is made: durable at once,
|
|
552
|
+
// visible to the next composition, and absent from the surface the model reads.
|
|
553
|
+
const appendRecord = typeof hostAppendRecord === 'function' ? hostAppendRecord
|
|
554
|
+
: typeof dshSession.appendPluginRecord === 'function' ? dshSession.appendPluginRecord : undefined
|
|
555
|
+
// The committed log, read through the surface the selected Host publishes: an
|
|
556
|
+
// array-backed fixture states `events`, a Host Session states `snapshotEvents()`.
|
|
557
|
+
const sessionEvents = session => Array.isArray(session?.events) ? session.events
|
|
558
|
+
: typeof session?.snapshotEvents === 'function' ? session.snapshotEvents()
|
|
559
|
+
: histories.get(session) ?? []
|
|
560
|
+
const sessionQueryService = () => typeof ctx.get === 'function' ? ctx.get('sessionQuery') : ctx.sessionQuery
|
|
441
561
|
async function observeHistory(agent, signal) {
|
|
442
|
-
const query =
|
|
562
|
+
const query = sessionQueryService()
|
|
443
563
|
const log = sessionHistoryForAgent ? await sessionHistoryForAgent(agent, signal) : await readSessionLog(agent.session, query, signal)
|
|
444
564
|
const events = log?.events ?? []
|
|
445
565
|
histories.set(agent.session, events)
|
|
@@ -484,23 +604,35 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
484
604
|
return directories?.get(agent.session) ?? agent.session.header.cwd
|
|
485
605
|
}
|
|
486
606
|
|
|
487
|
-
|
|
607
|
+
/**
|
|
608
|
+
* The Skill catalogue one supply view answers: a scope selects the viewing
|
|
609
|
+
* agent's layers, a directory selects workspace-sensitive Skills. A view with no
|
|
610
|
+
* scope is the Host's own declaration; a session whose scope the Host no longer
|
|
611
|
+
* holds reads the face its record froze instead, because the layers that scope
|
|
612
|
+
* resolved cannot be recovered without it.
|
|
613
|
+
*/
|
|
614
|
+
async function observeSkillsAt(target, signal) {
|
|
488
615
|
if (!readSkills(ctx)?.snapshot) return { skills: [], complete: true }
|
|
489
616
|
// Read the original provider layers through a short-lived child scope. The
|
|
490
617
|
// session projection is exact-scope-only, so catalogue reads cannot toggle
|
|
491
618
|
// a global inventory flag and create a window where closed Skills load.
|
|
492
619
|
const child = {}
|
|
493
|
-
const isolated = typeof ctx.plugin === 'function'
|
|
494
|
-
|
|
620
|
+
const isolated = target.scope !== undefined && typeof ctx.plugin === 'function'
|
|
621
|
+
? createScope(ctx, child, { parent: target.scope }) : null
|
|
622
|
+
const view = isolated ? child : target.scope
|
|
495
623
|
let observed
|
|
496
624
|
try {
|
|
497
|
-
const combined = [signal,
|
|
498
|
-
observed = await readSkills(ctx).snapshot({ cwd:
|
|
625
|
+
const combined = [signal, target.signal].filter(Boolean)
|
|
626
|
+
observed = await readSkills(ctx).snapshot({ cwd: target.cwd, scope: view,
|
|
499
627
|
signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0] })
|
|
500
628
|
} finally { await isolated?.dispose() }
|
|
501
629
|
return { ...observed, skills: observed.skills.filter(skill => isModelInvocable(skill) || isUserInvocable(skill)) }
|
|
502
630
|
}
|
|
503
631
|
|
|
632
|
+
function observeSkills(agent, signal) {
|
|
633
|
+
return observeSkillsAt({ scope: agent, cwd: currentDirectory(agent), signal: agent.signal }, signal)
|
|
634
|
+
}
|
|
635
|
+
|
|
504
636
|
function recordedSkills(agent, state) {
|
|
505
637
|
const skills = new Map(state.snapshot.capabilities.filter(item => item.category === 'skill').map(item => [item.id, item]))
|
|
506
638
|
const history = sessionEvents(agent.session)
|
|
@@ -535,9 +667,24 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
535
667
|
return state
|
|
536
668
|
}
|
|
537
669
|
|
|
538
|
-
|
|
670
|
+
/**
|
|
671
|
+
* Declare the capabilities of one supply view.
|
|
672
|
+
*
|
|
673
|
+
* `access` names the view when it is not the agent's own: `schemas` is the tool
|
|
674
|
+
* registry projection and `skills` an already-observed Skill catalogue. Both are
|
|
675
|
+
* what a view answers, so the caller that observed them decides which view this
|
|
676
|
+
* declaration describes; passing them keeps one declaration path for every view
|
|
677
|
+
* instead of a second inventory that could disagree with this one.
|
|
678
|
+
*
|
|
679
|
+
* `admitted` is a narrower statement of the same view: the names a step delivers. It
|
|
680
|
+
* restricts this environment's own contract view instead of replacing it, because a
|
|
681
|
+
* contract states the owner's output schema and only the SDK projection carries it — a
|
|
682
|
+
* capability recorded from a model-facing definition would promise `unknown`.
|
|
683
|
+
*/
|
|
684
|
+
async function inventory(agent, sections = [], settings, signal, presentation, frozenIds, skillObservation, access) {
|
|
539
685
|
if (settings) settings = effectiveCapabilitySettings(settings)
|
|
540
|
-
const schemas = toolSchemasForAgent(agent).filter(schema => !TRANSPORTS.has(schema.name)
|
|
686
|
+
const schemas = (access?.schemas ?? toolSchemasForAgent(agent)).filter(schema => !TRANSPORTS.has(schema.name)
|
|
687
|
+
&& (access?.admitted === undefined || access.admitted.has(schema.name)))
|
|
541
688
|
const capabilities = schemas.map(schema => toolCapability(schema, sections))
|
|
542
689
|
const declaredServers = new Set([...resourceServers(sections),
|
|
543
690
|
...sections.filter(section => section.name?.startsWith('mcp:')).map(section => section.name.slice(4))])
|
|
@@ -545,16 +692,39 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
545
692
|
const servers = [...declaredServers].filter(server => capability.schema.name.startsWith(`mcp__${server}__`))
|
|
546
693
|
if (servers.length === 1) capability.mcpServer = servers[0]
|
|
547
694
|
else if (servers.length > 1) capability.mcpServerCandidates = servers
|
|
695
|
+
// The source names every declared server whose namespace begins this tool name.
|
|
696
|
+
// One name can begin with two (`mcp__docs__deep__search` is named by `docs` and
|
|
697
|
+
// by `docs__deep`), and that list is the supply's own fact: the panel states the
|
|
698
|
+
// servers it has, so dropping the list here leaves the row unable to name any.
|
|
548
699
|
presentation?.set(capability.id, { name: capability.schema.name,
|
|
549
700
|
source: servers.length === 1 ? { kind: 'mcp', server: servers[0] } : servers.length > 1
|
|
550
|
-
? { kind: '
|
|
701
|
+
? { kind: 'mcp', servers } : { kind: 'host' } })
|
|
551
702
|
}
|
|
552
703
|
if (skillObservation) skillObservation.complete = true
|
|
553
|
-
const hasOperation = name => (!frozenIds || frozenIds.has(`tools.${name}`)) && capabilities.some(item => item.id === `tools.${name}`
|
|
554
|
-
&& (!settings?.enabled || !settings.policy || capabilityRule(settings.policy, item).availability !== 'closed'))
|
|
555
704
|
const policyPaths = Boolean(settings?.policy)
|
|
705
|
+
/**
|
|
706
|
+
* Whether a decision closes this capability.
|
|
707
|
+
*
|
|
708
|
+
* A saved rule and a session record answer one question, so both layers are read here:
|
|
709
|
+
* the layer that states a closure does not change what it means. A plan composed before
|
|
710
|
+
* the policy generation keeps the legacy settings shape (overrides, no policy), and a
|
|
711
|
+
* closure recorded into it has to reach the capabilities whose only model path it is.
|
|
712
|
+
*/
|
|
713
|
+
const closedByDecision = item => Boolean(settings?.enabled) && (settings.policy
|
|
714
|
+
? capabilityRule(settings.policy, item).availability === 'closed'
|
|
715
|
+
: settings.manual.overrides[item.id] === 'closed')
|
|
716
|
+
/**
|
|
717
|
+
* Whether this composition delivers one shared operation — the definition exists in the
|
|
718
|
+
* inventory and no decision closes it. The legacy layer states no availability for a
|
|
719
|
+
* definition the Host does not declare at all, so that absence is not a closure there;
|
|
720
|
+
* a decision is a closure in both layers.
|
|
721
|
+
*/
|
|
722
|
+
const hasOperation = name => (!frozenIds || frozenIds.has(`tools.${name}`))
|
|
723
|
+
&& capabilities.some(item => item.id === `tools.${name}` && !closedByDecision(item))
|
|
724
|
+
const operationClosed = name => capabilities.some(item => item.id === `tools.${name}` && closedByDecision(item))
|
|
725
|
+
const operationPath = name => hasOperation(name) || (!policyPaths && !operationClosed(name))
|
|
556
726
|
if (readSkills(ctx)?.snapshot !== undefined) {
|
|
557
|
-
const observed = await observeSkills(agent, signal)
|
|
727
|
+
const observed = access?.skills ?? await observeSkills(agent, signal)
|
|
558
728
|
if (skillObservation) {
|
|
559
729
|
skillObservation.complete = observed.complete
|
|
560
730
|
skillObservation.partialNames = observed.complete ? [] : observed.skills.map(skill => skill.name)
|
|
@@ -563,29 +733,30 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
563
733
|
// A partial catalog cannot prove a withdrawal or a new Skill. Preserve only
|
|
564
734
|
// the last complete view; native dispatch still uses the live Host scope.
|
|
565
735
|
if (!observed.complete) capabilities.push(...skillObservation.retained.map(item =>
|
|
566
|
-
retainedSkillCapability(item,
|
|
736
|
+
retainedSkillCapability(item, operationPath('skill'))))
|
|
567
737
|
for (const skill of observed.complete ? observed.skills : []) {
|
|
568
|
-
capabilities.push(skillCapability(skill,
|
|
738
|
+
capabilities.push(skillCapability(skill, operationPath('skill')))
|
|
569
739
|
presentation?.set(`skills.${skill.name}`, { name: skill.name,
|
|
570
740
|
source: { kind: 'skill', bucket: skill.source, provider: skill.provider },
|
|
571
741
|
invocation: { modelInvocable: isModelInvocable(skill), userInvocable: isUserInvocable(skill) } })
|
|
572
742
|
}
|
|
573
743
|
}
|
|
574
744
|
const servers = resourceServers(sections)
|
|
575
|
-
const resourceOperations =
|
|
576
|
-
|
|
745
|
+
const resourceOperations = RESOURCE_OPERATIONS.filter(name => operationPath(name))
|
|
746
|
+
const resourceNotice = resourceOperationNotice(resourceOperations, policyPaths)
|
|
577
747
|
for (const server of servers) capabilities.push({ id: `mcp.${server}`, category: CAPABILITY_INVENTORY_CATEGORIES.resource, mcpServer: server,
|
|
578
748
|
revision: digest({ server, instructions: sections.find(section => section.name === `mcp:${server}`)?.text ?? '' }),
|
|
579
|
-
dependencies: sectionDependencies(sections.filter(section => section.name === `mcp:${server}`)),
|
|
580
|
-
|
|
581
|
-
|
|
749
|
+
dependencies: sectionDependencies(sections.filter(section => section.name === `mcp:${server}`)),
|
|
750
|
+
...(resourceOperations.length === 0 && (policyPaths || RESOURCE_OPERATIONS.some(operationClosed))
|
|
751
|
+
? { effectiveAvailability: 'closed' } : {}),
|
|
752
|
+
contract: `MCP resource server ${JSON.stringify(server)}. ${resourceNotice}`,
|
|
582
753
|
description: `MCP resource access registered by the Host for server ${server}. This records the registered resource layer and the session's resource operations, not a proved list of exposed resources or templates.`,
|
|
583
754
|
selector: { server } })
|
|
584
755
|
for (const server of servers) presentation?.set(`mcp.${server}`, { name: server, source: { kind: 'mcp', server } })
|
|
585
756
|
return capabilities.filter(item => {
|
|
586
757
|
if (item.category !== 'tool') return true
|
|
587
758
|
const category = item.schema.name === 'skill' ? 'skill'
|
|
588
|
-
:
|
|
759
|
+
: RESOURCE_OPERATIONS.includes(item.schema.name) ? 'resource' : undefined
|
|
589
760
|
return category === undefined || capabilities.some(member => member.category === category
|
|
590
761
|
&& (category !== 'skill' || member.invocation.modelInvocable)
|
|
591
762
|
&& (!settings?.enabled || (settings.policy ? capabilityRule(settings.policy, member).availability !== 'closed' : settings.manual.overrides[member.id] !== 'closed'))
|
|
@@ -593,8 +764,105 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
593
764
|
})
|
|
594
765
|
}
|
|
595
766
|
|
|
767
|
+
/**
|
|
768
|
+
* What this session's log states about capability membership, as of this read.
|
|
769
|
+
*
|
|
770
|
+
* The composition is a reading of the record, not the record itself. A decision
|
|
771
|
+
* written after the last composition is already the session's newest statement, so a
|
|
772
|
+
* projection that asked only the frozen plan would keep declaring a capability the
|
|
773
|
+
* user closed until some later step happened to recompose — and in that window the
|
|
774
|
+
* SDK block, the program members and the explorable metadata answered the same
|
|
775
|
+
* question from two different inputs. Every projection therefore consults this
|
|
776
|
+
* reading, and the plan decides only what the record leaves open.
|
|
777
|
+
*
|
|
778
|
+
* The log is the record even for an agent no composition exists for yet, which is
|
|
779
|
+
* what lets a first assembly answer from the log instead of declaring everything.
|
|
780
|
+
*/
|
|
781
|
+
const NO_DECISIONS = Object.freeze({ closed: new Set(), servers: new Set() })
|
|
782
|
+
const decisionsBySession = new WeakMap()
|
|
783
|
+
function recordDecisions(session) {
|
|
784
|
+
if (session === undefined || session === null) return NO_DECISIONS
|
|
785
|
+
const events = sessionEvents(session)
|
|
786
|
+
const cached = decisionsBySession.get(session)
|
|
787
|
+
// The log is append-only, so its length and its last event identify one reading.
|
|
788
|
+
if (cached !== undefined && cached.length === events.length
|
|
789
|
+
&& cached.last === events[events.length - 1]) return cached.decisions
|
|
790
|
+
const closed = new Set()
|
|
791
|
+
const servers = new Set()
|
|
792
|
+
let statement
|
|
793
|
+
try {
|
|
794
|
+
statement = capabilitySelectionOf(events, String(session.id))
|
|
795
|
+
} catch {
|
|
796
|
+
// A torn record states no decision this build can read; the composition reports the
|
|
797
|
+
// damage on its own path, and a projection must not fail because of it.
|
|
798
|
+
statement = undefined
|
|
799
|
+
}
|
|
800
|
+
if (statement?.state === 'decided') {
|
|
801
|
+
for (const [id, choice] of Object.entries(statement.selection.choices)) {
|
|
802
|
+
if (capabilityRuleOfSelection(choice).availability !== 'closed') continue
|
|
803
|
+
if (id.startsWith('mcpServers.')) servers.add(id.slice('mcpServers.'.length))
|
|
804
|
+
else closed.add(id)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const decisions = Object.freeze({ closed, servers })
|
|
808
|
+
decisionsBySession.set(session, { length: events.length, last: events[events.length - 1], decisions })
|
|
809
|
+
return decisions
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Whether the record closes this capability.
|
|
813
|
+
*
|
|
814
|
+
* A server decides its members, so a recorded `mcpServers.<server>` closure reaches
|
|
815
|
+
* them. The live capability's own server identity answers when it exists; otherwise the
|
|
816
|
+
* name is the only evidence, which is exactly how the Host names those members.
|
|
817
|
+
*/
|
|
818
|
+
function recordCloses(decisions, id, capability = undefined) {
|
|
819
|
+
if (decisions.closed.has(id)) return true
|
|
820
|
+
if (decisions.servers.size === 0) return false
|
|
821
|
+
if (capability?.mcpServer !== undefined) return decisions.servers.has(capability.mcpServer)
|
|
822
|
+
if (id.startsWith('mcp.')) return decisions.servers.has(id.slice('mcp.'.length))
|
|
823
|
+
if (!id.startsWith('tools.')) return false
|
|
824
|
+
const name = id.slice('tools.'.length)
|
|
825
|
+
return [...decisions.servers].some(server => name.startsWith(`mcp__${server}__`))
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Whether the record closes one member of the `tools` namespace.
|
|
829
|
+
*
|
|
830
|
+
* The two transports are the call channel rather than capabilities: `run_code` is how a
|
|
831
|
+
* program is called and `edit_run_code` is callable inside every program, so no record
|
|
832
|
+
* makes either unavailable.
|
|
833
|
+
*/
|
|
834
|
+
function recordClosesMember(decisions, name) {
|
|
835
|
+
return !TRANSPORTS.has(name) && recordCloses(decisions, `tools.${name}`)
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* The record's closures, when capability management is what applies them.
|
|
839
|
+
*
|
|
840
|
+
* A paused context or a disabled management switch delegates the Host's capability surface:
|
|
841
|
+
* this environment composes nothing and filters nothing, so a choice the session recorded
|
|
842
|
+
* earlier stays a stored choice that applies again once management is on. Reading the record
|
|
843
|
+
* there would enforce a closure the switch suspended — and only half enforce it, because the
|
|
844
|
+
* same switch also lets the Host execute that member.
|
|
845
|
+
*
|
|
846
|
+
* A session the Host still holds keeps the composition it froze: switching management off does
|
|
847
|
+
* not rewrite an existing session, so its own snapshot states whether this environment still
|
|
848
|
+
* applies. An agent with no composition yet answers from the configuration it would compose
|
|
849
|
+
* under.
|
|
850
|
+
*/
|
|
851
|
+
function decisionsOf(state, session = state?.session) {
|
|
852
|
+
const settings = effectiveCapabilitySettings(state?.snapshot.settings ?? capabilityConfiguration(currentConfig))
|
|
853
|
+
return settings.enabled === true ? recordDecisions(session) : NO_DECISIONS
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* The same answer for a caller that holds the agent.
|
|
857
|
+
*
|
|
858
|
+
* A projection may run before this agent has a composition — a restart, or a step assembled
|
|
859
|
+
* ahead of one — and the session log is still the record it reads, so the agent is what names
|
|
860
|
+
* the session here rather than the composition state.
|
|
861
|
+
*/
|
|
862
|
+
function decisionsFor(agent) {
|
|
863
|
+
return decisionsOf(stateFor(agent), agent?.session)
|
|
864
|
+
}
|
|
596
865
|
function stateFor(agent) { return sessions.get(String(agent?.session?.id ?? agent?.id)) }
|
|
597
|
-
const canRefresh = agent => modelSchemasForAgent(agent).some(schema => schema.name === 'run_code')
|
|
598
866
|
function planCommitted(agent, message) {
|
|
599
867
|
return message !== undefined && (sessionEvents(agent.session).some(event =>
|
|
600
868
|
event.type === 'user/message' && event.data?.id === message.id)
|
|
@@ -608,6 +876,43 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
608
876
|
if (!sessionEvents(agent.session).some(event => event.type === 'system/message')) return
|
|
609
877
|
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
|
610
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* A session capability choice is a record, not a plan edit and not a message.
|
|
881
|
+
*
|
|
882
|
+
* The panel states what this session decided, so the decision itself has to exist in
|
|
883
|
+
* the session's own log. The plan is what a runtime later materializes from it; a
|
|
884
|
+
* change that only rebuilt a plan would leave the row stating the value it replaced.
|
|
885
|
+
*
|
|
886
|
+
* A decision is made when the control is used, and nothing about that moment requires a
|
|
887
|
+
* turn: it is the session's statement about itself, taken through the host's plugin-record
|
|
888
|
+
* writer, durable at once and outside the model-visible surface. Writing it as a message
|
|
889
|
+
* instead would need the host's protected surface head first, and a node ahead of that
|
|
890
|
+
* head leaves the whole log unreadable — which is why this decision never travels that way.
|
|
891
|
+
*/
|
|
892
|
+
function recordSelection(agent, overrides, signal) {
|
|
893
|
+
signal?.throwIfAborted()
|
|
894
|
+
const session = agent?.session
|
|
895
|
+
if (typeof session?.append !== 'function') {
|
|
896
|
+
throw new Error('ptc-plus: session capability choices require the public Session.append surface')
|
|
897
|
+
}
|
|
898
|
+
if (appendRecord === undefined) {
|
|
899
|
+
throw new Error('ptc-plus: this DSH release publishes no plugin-record writer, so a session capability choice cannot be recorded')
|
|
900
|
+
}
|
|
901
|
+
const choices = {}
|
|
902
|
+
for (const [id, value] of Object.entries(overrides ?? {})) {
|
|
903
|
+
const rule = capabilityRuleOfSelection(value)
|
|
904
|
+
if (Object.keys(rule).length > 0) choices[id] = rule
|
|
905
|
+
}
|
|
906
|
+
const seq = appendRecord(session, CAPABILITY_SELECTION_RECORD, { schema: CAPABILITY_SELECTION_SCHEMA, choices })
|
|
907
|
+
// This record is the session's newest statement, and it is already in the log the
|
|
908
|
+
// readers observe, so the next read answers from it without re-observing the session.
|
|
909
|
+
const observed = histories.get(session)
|
|
910
|
+
if (Array.isArray(observed)) {
|
|
911
|
+
const committed = sessionEvents(session).find(event => event.seq === seq)
|
|
912
|
+
if (committed !== undefined) histories.set(session, [...observed, committed])
|
|
913
|
+
}
|
|
914
|
+
return 'recorded'
|
|
915
|
+
}
|
|
611
916
|
function selection(state, id) { return state?.snapshot.plan.entries.find(entry => entry.id === id)?.selection }
|
|
612
917
|
function liveCapability(state, id) {
|
|
613
918
|
return state?.liveCapabilities?.get(id) ?? state?.snapshot.capabilities.find(item => item.id === id)
|
|
@@ -625,6 +930,9 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
625
930
|
* member's identity from the model-visible surface.
|
|
626
931
|
*/
|
|
627
932
|
function declaredSelection(state, id) {
|
|
933
|
+
// A closure the record states is the session's own decision and is already durable;
|
|
934
|
+
// waiting for the next composition would declare a capability the user closed.
|
|
935
|
+
if (recordCloses(decisionsOf(state), id, liveCapability(state, id))) return 'closed'
|
|
628
936
|
const planned = selection(state, id)
|
|
629
937
|
if (planned !== undefined || !state?.snapshot.plan.enabled) return planned
|
|
630
938
|
const live = state.liveCapabilities?.get(id)
|
|
@@ -635,17 +943,23 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
635
943
|
return overrides[id] === 'closed'
|
|
636
944
|
|| (live.mcpServer !== undefined && overrides[`mcpServers.${live.mcpServer}`] === 'closed') ? 'closed' : 'host'
|
|
637
945
|
}
|
|
638
|
-
function supplied(state, id) {
|
|
946
|
+
function supplied(state, id, decisions = decisionsOf(state)) {
|
|
947
|
+
if (recordCloses(decisions, id, liveCapability(state, id))) return false
|
|
639
948
|
if (state?.liveCapabilities && (!state.liveCapabilities.has(id) || state.liveCapabilities.get(id).effectiveAvailability === 'closed')) return false
|
|
640
949
|
if (!state?.snapshot.plan.enabled) return Boolean(state?.liveCapabilities) || !state?.snapshot.settings.policy || selection(state, id) !== 'closed'
|
|
641
950
|
if (!['full', 'on-demand', 'host'].includes(declaredSelection(state, id))) return false
|
|
642
951
|
const capability = liveCapability(state, id)
|
|
643
|
-
|
|
644
|
-
|
|
952
|
+
if (capability?.category !== 'resource') return true
|
|
953
|
+
// A resource row is explorable through the operations its contract names, so it is
|
|
954
|
+
// supplied only while this session still delivers an operation to name — and the
|
|
955
|
+
// guidance and directory the row owns follow the same answer. A composition can state
|
|
956
|
+
// the row before a decision takes the last operation away.
|
|
957
|
+
return resourceServers(state.hostSections).includes(capability.selector.server)
|
|
958
|
+
&& RESOURCE_OPERATIONS.some(name => supplied(state, `tools.${name}`, decisions))
|
|
645
959
|
}
|
|
646
960
|
function sharedMembers(state, name) {
|
|
647
961
|
const category = name === 'skill' ? 'skill'
|
|
648
|
-
:
|
|
962
|
+
: RESOURCE_OPERATIONS.includes(name) ? 'resource' : undefined
|
|
649
963
|
return category === undefined ? undefined : [...declaredCapabilities(state).values()]
|
|
650
964
|
.filter(item => item.category === category && supplied(state, item.id)
|
|
651
965
|
&& (category !== 'skill' || (state.liveCapabilities?.get(item.id) ?? item).invocation?.modelInvocable !== false))
|
|
@@ -654,6 +968,31 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
654
968
|
function memberSupplied(state, name) {
|
|
655
969
|
return TRANSPORTS.has(name) || (supplied(state, `tools.${name}`) && sharedMembers(state, name)?.length !== 0)
|
|
656
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* The invocation paths a declared row can state for one step.
|
|
973
|
+
*
|
|
974
|
+
* A Skill row is explorable through the shared loader and a resource server row through the
|
|
975
|
+
* Host's three resource operations, so those four members are the only ones whose contract
|
|
976
|
+
* *is* a call. `supplied` states what this session still has and the composition states the
|
|
977
|
+
* paths it was built under; `admitted` is the third input — the definitions this step
|
|
978
|
+
* delivers — and a caller that observed none keeps the composition's answer, because a
|
|
979
|
+
* withheld definition is a statement about the step, not about the session.
|
|
980
|
+
*
|
|
981
|
+
* A delivered path is therefore the intersection: what the step delivers, what a plan froze
|
|
982
|
+
* and what the session still has, so a row never gains a path the composition did not state.
|
|
983
|
+
* Without a plan there is nothing frozen to intersect with — the record is the whole
|
|
984
|
+
* composition — and `supplied` alone answers it.
|
|
985
|
+
*/
|
|
986
|
+
function sharedPaths(state, admitted, decisions = decisionsOf(state)) {
|
|
987
|
+
const shared = name => admitted === undefined || admitted.has(name)
|
|
988
|
+
const planned = state?.snapshot.plan.enabled === true
|
|
989
|
+
return {
|
|
990
|
+
loader: shared('skill') && supplied(state, 'tools.skill', decisions),
|
|
991
|
+
operations: RESOURCE_OPERATIONS.filter(name => shared(name)
|
|
992
|
+
&& (!planned || selection(state, `tools.${name}`) !== undefined)
|
|
993
|
+
&& supplied(state, `tools.${name}`, decisions)),
|
|
994
|
+
}
|
|
995
|
+
}
|
|
657
996
|
function statisticsSnapshot(state) {
|
|
658
997
|
if (!state.liveCapabilities) return state.snapshot
|
|
659
998
|
const eligible = item => state.skillInventoryComplete !== false || item.category !== 'skill'
|
|
@@ -795,52 +1134,106 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
795
1134
|
return previewPolicies.get(cap)
|
|
796
1135
|
}
|
|
797
1136
|
|
|
798
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* Compose this session's capability plan from the record that owns its choices.
|
|
1139
|
+
*
|
|
1140
|
+
* The session's own log states this session's choices and the saved
|
|
1141
|
+
* configuration states what it inherits; both are read on every composition,
|
|
1142
|
+
* including the incremental one a step begins with. A composition may not
|
|
1143
|
+
* remember them: a choice recorded before the process restarted exists in the
|
|
1144
|
+
* log and in no memory, and a plan that trusted its own earlier composition
|
|
1145
|
+
* would keep a decision the log has already replaced.
|
|
1146
|
+
*
|
|
1147
|
+
* A plan whose recorded choices already match the log is kept as it is. One that
|
|
1148
|
+
* disagrees is recomposed from the same saved inputs it was composed from, so a
|
|
1149
|
+
* session choice never silently adopts a saved configuration the session has not
|
|
1150
|
+
* refreshed to. `force` remains the explicit refresh: it recomposes from the
|
|
1151
|
+
* saved configuration as it stands now and publishes immediately.
|
|
1152
|
+
*
|
|
1153
|
+
* `delivered` is the step's admitted definition set when the caller observed one. It
|
|
1154
|
+
* is the same set the Host renders its own declaration from, so composing from it
|
|
1155
|
+
* keeps the plan, the declaration and the program members about one capability set;
|
|
1156
|
+
* a composition that has none to offer keeps the admission it already observed,
|
|
1157
|
+
* because a caller with no observation is not asking the Host to declare more.
|
|
1158
|
+
*/
|
|
1159
|
+
async function prepare(agent, sections, force = false, signal = agent?.signal, delivered = undefined) {
|
|
799
1160
|
signal?.throwIfAborted()
|
|
800
1161
|
if (agent?.session === undefined) {
|
|
801
1162
|
if (!effectiveCapabilitySettings(capabilityConfiguration(currentConfig)).enabled) return undefined
|
|
802
1163
|
throw new TypeError('ptc-plus: capability management requires an agent Session')
|
|
803
1164
|
}
|
|
804
|
-
await observeHistory(agent, signal)
|
|
805
|
-
await reconcilePendingChoice(agent, signal)
|
|
1165
|
+
const history = await observeHistory(agent, signal)
|
|
806
1166
|
const id = String(agent.session.id ?? agent.id)
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1167
|
+
const statement = capabilitySelectionOf(history, id)
|
|
1168
|
+
const sessionOverrides = sessionOverridesOf(statement)
|
|
1169
|
+
const current = sessions.get(id)
|
|
1170
|
+
if (!force && current !== undefined) {
|
|
1171
|
+
if (sections !== undefined) current.hostSections = sections
|
|
1172
|
+
if (delivered !== undefined) current.hostTools = delivered
|
|
1173
|
+
if (sameChoices(sessionOverrides, current.snapshot.sessionOverrides)) return qualifyCapabilities(agent, current, signal)
|
|
811
1174
|
}
|
|
1175
|
+
// A composition that is rebuilt for a record keeps the Host sections it already
|
|
1176
|
+
// observed: a caller that has none to offer is not asking the Host to declare
|
|
1177
|
+
// less than it did, only to act on what the session recorded.
|
|
1178
|
+
const hostSections = sections ?? current?.hostSections ?? []
|
|
1179
|
+
const hostTools = delivered ?? current?.hostTools
|
|
812
1180
|
const pending = operations.get(agent)
|
|
813
1181
|
if (pending !== undefined && !force) return pending
|
|
814
1182
|
const task = (async () => {
|
|
1183
|
+
// The composition this one replaces, when it replaces one: a live state, or
|
|
1184
|
+
// the plan the log last published. Its saved inputs are reused so the
|
|
1185
|
+
// replacement states the same inheritance; its plan is not reused.
|
|
1186
|
+
let preceding
|
|
815
1187
|
if (!force) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1188
|
+
if (current !== undefined) preceding = current.snapshot
|
|
1189
|
+
else {
|
|
1190
|
+
const recorded = recordedPlan(history, id)
|
|
1191
|
+
if (recorded !== undefined) {
|
|
1192
|
+
let snapshot
|
|
1193
|
+
try { snapshot = sourcePlan(recorded, id) } catch (error) {
|
|
1194
|
+
ctx.logger?.warn?.(`ptc-plus: ${error.message}; continuing with the host environment until /capabilities refresh`)
|
|
1195
|
+
const settings = { ...capabilityConfiguration(currentConfig), enabled: false, ...(currentConfig.capabilityPolicy ? { recoveryEnabled: false, previewEnabled: false, statistics: { ...capabilityConfiguration(currentConfig).statistics, enabled: false } } : {}) }
|
|
1196
|
+
const plan = createCapabilityPlan({ ...settings, configurationRevision: digest(settings), capabilities: [], navigationText: NAVIGATION })
|
|
1197
|
+
snapshot = deepFreeze({ schema: settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3' : 'ptc-capability-environment/v1', ...(settings.policy || settings.contextEnabled !== undefined ? { sourceSettings: settings, sessionOverrides: {} } : {}), navigationText: NAVIGATION, settings, capabilities: [], plan })
|
|
1198
|
+
const notice = planMessage(snapshot, id, true)
|
|
1199
|
+
const state = { snapshot, message: notice, hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map() }
|
|
1200
|
+
return acceptState(agent, state, true, signal)
|
|
1201
|
+
}
|
|
1202
|
+
if (sameChoices(sessionOverrides, snapshot.sessionOverrides)) {
|
|
1203
|
+
const restored = { snapshot, message: snapshot.settings.contextEnabled !== undefined || snapshot.plan.enabled || snapshot.settings.policy && (snapshot.settings.statistics?.enabled || snapshot.settings.recoveryEnabled || snapshot.settings.previewEnabled) || recorded.source.refresh ? recorded : undefined, hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map() }
|
|
1204
|
+
await qualifyCapabilities(agent, restored, signal)
|
|
1205
|
+
signal?.throwIfAborted()
|
|
1206
|
+
restoreEvidence(agent, restored)
|
|
1207
|
+
return acceptState(agent, restored, false, signal)
|
|
1208
|
+
}
|
|
1209
|
+
preceding = snapshot
|
|
828
1210
|
}
|
|
829
|
-
const restored = { snapshot, message: snapshot.settings.contextEnabled !== undefined || snapshot.plan.enabled || snapshot.settings.policy && (snapshot.settings.statistics?.enabled || snapshot.settings.recoveryEnabled || snapshot.settings.previewEnabled) || recorded.source.refresh ? recorded : undefined, hostSections: sections ?? [], expanded: new Set(), used: new Map() }
|
|
830
|
-
await qualifyCapabilities(agent, restored, signal)
|
|
831
|
-
signal?.throwIfAborted()
|
|
832
|
-
restoreEvidence(agent, restored)
|
|
833
|
-
return acceptState(agent, restored, false, signal)
|
|
834
1211
|
}
|
|
835
1212
|
}
|
|
836
1213
|
const historical = !force && !sessionEvents(agent.session).some(event => event.type === 'user/message'
|
|
837
1214
|
&& readRuntimeAvailability(event.data) === 'disabled') && sessionEvents(agent.session).some(event =>
|
|
838
1215
|
event.type === 'assistant/message' || event.type === 'tool/result')
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1216
|
+
let sourceSettings = preceding === undefined
|
|
1217
|
+
? { ...capabilityConfiguration(currentConfig), ...(historical ? { enabled: false, ...(currentConfig.capabilityPolicy ? { recoveryEnabled: false, previewEnabled: false, statistics: { ...capabilityConfiguration(currentConfig).statistics, enabled: false } } : {}) } : {}) }
|
|
1218
|
+
: preceding.sourceSettings ?? preceding.settings
|
|
1219
|
+
// A record can state one dimension where a saved decision states a whole
|
|
1220
|
+
// selection. The policy vocabulary can hold either, and it is derived from the
|
|
1221
|
+
// same saved document, so the composition upgrades to it here rather than
|
|
1222
|
+
// dropping a dimension the record decided.
|
|
1223
|
+
if (sourceSettings.policy === undefined
|
|
1224
|
+
&& Object.values(sessionOverrides).some(selection => typeof selection === 'object')) {
|
|
1225
|
+
sourceSettings = capabilityConfiguration(capabilityProfileConfiguration(
|
|
1226
|
+
savedConfiguration(sourceSettings), { allowInvalidStatistics: true }))
|
|
1227
|
+
}
|
|
842
1228
|
const settings = sessionCapabilitySettings(sourceSettings, sessionOverrides)
|
|
843
|
-
|
|
1229
|
+
// The admission restricts this composition to the definitions the step delivers, and it
|
|
1230
|
+
// is applied to this environment's own contract view: the step publishes model-facing
|
|
1231
|
+
// schemas, which carry no output schema, so reading them as the contracts would record
|
|
1232
|
+
// a capability whose call promises `unknown`.
|
|
1233
|
+
const capabilities = requiresCapabilityInventory(settings)
|
|
1234
|
+
? await inventory(agent, hostSections, settings, signal, undefined, undefined, undefined,
|
|
1235
|
+
hostTools === undefined ? undefined : { admitted: new Set(hostTools.map(schema => schema.name)) })
|
|
1236
|
+
: []
|
|
844
1237
|
const effective = effectiveCapabilitySettings(settings)
|
|
845
1238
|
const coverage = (effective.policy ? effective.enabled && effective.policy.allocation.statistics : effective.statistics?.enabled === true) ? await store?.coverage(agent, capabilities, settings.statistics) : undefined
|
|
846
1239
|
const planChannel = planChannelText(currentConfig)
|
|
@@ -849,7 +1242,8 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
849
1242
|
capabilities, coverage, navigationText: planChannel.navigationSentence,
|
|
850
1243
|
categories: planChannel.categoryLines,
|
|
851
1244
|
directoryBudgetBytes })
|
|
852
|
-
const hasChoices = settings.contextEnabled !== undefined || settings.policy !== undefined
|
|
1245
|
+
const hasChoices = settings.contextEnabled !== undefined || settings.policy !== undefined
|
|
1246
|
+
|| statement.state !== 'none' || preceding?.schema === 'ptc-capability-environment/v2'
|
|
853
1247
|
// An edited plan channel needs the v5 record, and that generation requires the
|
|
854
1248
|
// saved inputs and session choices it was composed from.
|
|
855
1249
|
const recordsText = !planChannel.isDefault
|
|
@@ -860,43 +1254,387 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
860
1254
|
...(recordsText ? planPromptText(planChannel, directoryBudgetBytes) : {}),
|
|
861
1255
|
navigationText: planChannel.navigationSentence, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
|
|
862
1256
|
const initialized = sessionEvents(agent.session).some(event => event.type === 'system/message')
|
|
863
|
-
|
|
1257
|
+
// A composition that replaces one already delivered says so, whether the
|
|
1258
|
+
// replacement came from an explicit refresh or from a record the delivered
|
|
1259
|
+
// plan no longer matches.
|
|
1260
|
+
const message = planMessage(snapshot, id, (force || preceding !== undefined) && initialized)
|
|
864
1261
|
agent.signal?.throwIfAborted?.()
|
|
865
1262
|
signal?.throwIfAborted()
|
|
866
1263
|
// Initialized sessions commit a refresh and its notification atomically.
|
|
867
1264
|
// First-step proposals are accepted through the Host's pre-step messages.
|
|
868
1265
|
const state = { snapshot, message: settings.contextEnabled === undefined && !effective.enabled && !force && !(effective.policy && (effective.statistics?.enabled || effective.recoveryEnabled || effective.previewEnabled)) ? undefined : message,
|
|
869
|
-
hostSections:
|
|
1266
|
+
hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map(),
|
|
870
1267
|
...(requiresCapabilityInventory(settings) ? { liveCapabilities: new Map(capabilities.map(item => [item.id, item])) } : {}) }
|
|
871
1268
|
restoreEvidence(agent, state)
|
|
872
|
-
if (choice?.commandId && !initialized) {
|
|
873
|
-
state.pendingCommand = choice.commandId
|
|
874
|
-
state.pendingPrevious = stateFor(agent)
|
|
875
|
-
}
|
|
876
1269
|
return acceptState(agent, state, force, signal)
|
|
877
1270
|
})()
|
|
878
1271
|
operations.set(agent, task)
|
|
879
1272
|
try { return await task } finally { if (operations.get(agent) === task) operations.delete(agent) }
|
|
880
1273
|
}
|
|
881
1274
|
|
|
882
|
-
|
|
1275
|
+
function visibleSchemas(agent, schemas) {
|
|
883
1276
|
const state = stateFor(agent)
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1277
|
+
const decisions = decisionsFor(agent)
|
|
1278
|
+
const declared = schemas.filter(schema => !recordClosesMember(decisions, schema.name))
|
|
1279
|
+
if (!state?.snapshot.plan.enabled) return declared
|
|
1280
|
+
return declared.filter(schema => memberSupplied(state, schema.name))
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* What the Host declares to one supply view.
|
|
1285
|
+
*
|
|
1286
|
+
* A view is named by the scope the caller supplies, and the same three sources
|
|
1287
|
+
* answer it: prompt sections name the MCP servers and resource servers, the tool
|
|
1288
|
+
* registry answers that scope's view of the tools, and the Skill catalogue answers
|
|
1289
|
+
* the layers that scope resolves for a workspace directory. With an agent those
|
|
1290
|
+
* sources answer this session's declaration — a preset registers its tools at agent
|
|
1291
|
+
* scope, and only that scope resolves the Skill layers it selects. With no scope
|
|
1292
|
+
* they answer the Host's own declaration, which is what a caller that names no
|
|
1293
|
+
* session asks for.
|
|
1294
|
+
*
|
|
1295
|
+
* `agent` is the scope handle and nothing else. It says which declaration to read;
|
|
1296
|
+
* it is never asked whether the session is usable, and no runtime branch runs here.
|
|
1297
|
+
* The observation flag keeps the public assembly passive even when a scope is named:
|
|
1298
|
+
* it contributes that scope's sections without preparing or delivering anything.
|
|
1299
|
+
*/
|
|
1300
|
+
async function hostSupply({ agent, cwd }, signal) {
|
|
1301
|
+
const scope = agent === undefined ? {} : { agent, scope: agent }
|
|
1302
|
+
// One failure mode: a source that cannot be read fails this read. Partial rows
|
|
1303
|
+
// would be a screen that silently misses what exists, which no wording repairs.
|
|
1304
|
+
let sections = []
|
|
1305
|
+
if (typeof ctx.systemPrompt?.assemble === 'function') {
|
|
1306
|
+
const assembly = await ctx.systemPrompt.assemble({ ...scope, signal, ptcPlusCapabilityObservation: true })
|
|
1307
|
+
sections = Array.isArray(assembly?.sections) ? assembly.sections : []
|
|
1308
|
+
// Reading the sections is the MCP layer's own step. A section this build cannot
|
|
1309
|
+
// interpret fails here rather than being reported as a tool-registry failure.
|
|
1310
|
+
resourceServers(sections)
|
|
892
1311
|
}
|
|
893
|
-
|
|
1312
|
+
const schemas = toolSchemasForAgent(agent)
|
|
1313
|
+
const skills = readSkills(ctx)?.snapshot === undefined ? undefined
|
|
1314
|
+
: await observeSkillsAt({ ...scope, cwd, signal }, signal)
|
|
1315
|
+
const presentation = new Map()
|
|
1316
|
+
const capabilities = await inventory(agent, sections,
|
|
1317
|
+
{ ...capabilityConfiguration(currentConfig), enabled: false, manual: { baseline: [], overrides: {} } },
|
|
1318
|
+
signal, presentation, undefined, undefined, { schemas, skills })
|
|
1319
|
+
return { capabilities, presentation }
|
|
894
1320
|
}
|
|
895
1321
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1322
|
+
/**
|
|
1323
|
+
* The supply this session's own record froze.
|
|
1324
|
+
*
|
|
1325
|
+
* A session whose agent the Host no longer holds has no scope to observe, and the
|
|
1326
|
+
* Host's own plane is not that session's face: the preset tools registered at agent
|
|
1327
|
+
* scope and the Skill layers that scope resolved existed in that scope alone.
|
|
1328
|
+
* The plan this session recorded is the statement of what it was supplied, and
|
|
1329
|
+
* `snapshot.capabilities` is that statement. Name and source are taken from what the
|
|
1330
|
+
* recorded capability itself carries: a tool names itself through its schema and its
|
|
1331
|
+
* MCP owner, a Skill through its selector — the frozen contract records no provider
|
|
1332
|
+
* bucket for it — and a resource row through the server it belongs to.
|
|
1333
|
+
*/
|
|
1334
|
+
function frozenSupply(snapshot) {
|
|
1335
|
+
const presentation = new Map()
|
|
1336
|
+
for (const capability of snapshot.capabilities) {
|
|
1337
|
+
const name = capability.schema?.name ?? capability.selector?.name
|
|
1338
|
+
?? capability.selector?.server ?? capability.id
|
|
1339
|
+
const source = capability.mcpServer !== undefined ? { kind: 'mcp', server: capability.mcpServer }
|
|
1340
|
+
: capability.mcpServerCandidates !== undefined ? { kind: 'mcp', servers: capability.mcpServerCandidates }
|
|
1341
|
+
: { kind: capability.category === 'skill' ? 'skill'
|
|
1342
|
+
: capability.category === CAPABILITY_INVENTORY_CATEGORIES.resource ? 'mcp' : 'host' }
|
|
1343
|
+
presentation.set(capability.id, { name, source,
|
|
1344
|
+
...(capability.invocation === undefined ? {} : { invocation: capability.invocation }) })
|
|
1345
|
+
}
|
|
1346
|
+
return { capabilities: snapshot.capabilities, presentation }
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Which capabilities this session's own log proves it obtained.
|
|
1351
|
+
*
|
|
1352
|
+
* Evidence is a fact about one execution: a completed result that names the
|
|
1353
|
+
* capability in its evidence block. The absence of such a result is not a state
|
|
1354
|
+
* the panel states; it is an empty cell.
|
|
1355
|
+
*/
|
|
1356
|
+
function capabilityProofOf(events) {
|
|
1357
|
+
const proved = new Set()
|
|
1358
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
1359
|
+
const evidence = event?.type === 'tool/result' && event.data?.meta?.[EVIDENCE_KEY]
|
|
1360
|
+
if (!evidence || evidence.schema !== 'ptc-capability-evidence/v1' || !Array.isArray(evidence.used)) continue
|
|
1361
|
+
for (const id of evidence.used) if (typeof id === 'string') proved.add(id)
|
|
1362
|
+
}
|
|
1363
|
+
return proved
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const ROW_DIMENSIONS = Object.freeze(['availability', 'disclosure'])
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* The words this configuration wrote for one object.
|
|
1370
|
+
*
|
|
1371
|
+
* The saved column follows the same rule as the session column: it states what its own record
|
|
1372
|
+
* holds for that object. A rule the document wrote for the object is stated in the record's
|
|
1373
|
+
* words; a dimension it wrote nothing for reads `automatic`. A layer that would outrank that
|
|
1374
|
+
* entry at runtime — a category or `mcpServers.<server>` rule, or a document's bottom layer — is
|
|
1375
|
+
* behaviour rather than a word this (object, address) holds, so it changes neither the cell nor
|
|
1376
|
+
* the control. A server has no disclosure of its own, so a server row states availability only.
|
|
1377
|
+
*/
|
|
1378
|
+
function savedChoice(configuration, capability) {
|
|
1379
|
+
if (capability.kind === 'server') {
|
|
1380
|
+
// A server's saved address is the one `mcpServers.<server>` rule it has.
|
|
1381
|
+
const availability = configuration.policy
|
|
1382
|
+
? configuration.policy.mcpServers[capability.id.slice('mcpServers.'.length)]?.availability
|
|
1383
|
+
: capabilityOverrideWord(configuration, capability.id, 'availability')
|
|
1384
|
+
return { availability: availability ?? 'automatic' }
|
|
1385
|
+
}
|
|
1386
|
+
const rule = configuration.policy && Object.hasOwn(configuration.policy.items, capability.id)
|
|
1387
|
+
? configuration.policy.items[capability.id] : undefined
|
|
1388
|
+
// The override record is this address's write channel only while the document holds no
|
|
1389
|
+
// policy. Once it does, that policy is the record — the override field stays as historical
|
|
1390
|
+
// input and must not answer a dimension the policy left unwritten (the server branch above
|
|
1391
|
+
// has the same guard), otherwise §8.2's removal would be invisible.
|
|
1392
|
+
const override = dimension => configuration.policy === undefined
|
|
1393
|
+
? capabilityOverrideWord(configuration, capability.id, dimension) : undefined
|
|
1394
|
+
return { availability: rule?.availability ?? override('availability') ?? 'automatic',
|
|
1395
|
+
disclosure: rule?.disclosure ?? override('disclosure') ?? 'automatic' }
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* One dimension the legacy override record wrote for an object, in the record's own
|
|
1400
|
+
* words.
|
|
1401
|
+
*
|
|
1402
|
+
* The legacy record holds a whole selection: `closed` is an availability word and a
|
|
1403
|
+
* delivery word is a disclosure word. Neither writes the other dimension, so the
|
|
1404
|
+
* other dimension stays unwritten instead of borrowing a value from the resolution.
|
|
1405
|
+
*/
|
|
1406
|
+
function capabilityOverrideWord(configuration, id, dimension) {
|
|
1407
|
+
const override = configuration.manual?.overrides?.[id]
|
|
1408
|
+
if (override === undefined || override === 'automatic') return undefined
|
|
1409
|
+
if (override === 'closed') return dimension === 'availability' ? 'closed' : undefined
|
|
1410
|
+
return dimension === 'disclosure' ? override : undefined
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* The words this session's record wrote for one object, and which layer wrote each.
|
|
1415
|
+
*
|
|
1416
|
+
* One (object, address) holds one value, so a dimension this object's own entry wrote is
|
|
1417
|
+
* never also its server's: the layer map names a server only for a dimension this entry
|
|
1418
|
+
* wrote nothing for. The words stay the record's own because both the value column and the
|
|
1419
|
+
* receipt state what was written; the receipt names the layer that wrote them.
|
|
1420
|
+
*/
|
|
1421
|
+
function sessionWords(statement, capability) {
|
|
1422
|
+
const own = capabilityChoiceOf(statement, capability.id) ?? {}
|
|
1423
|
+
const serverId = capability.mcpServer === undefined ? undefined : `mcpServers.${capability.mcpServer}`
|
|
1424
|
+
const server = serverId === undefined ? {} : capabilityChoiceOf(statement, serverId) ?? {}
|
|
1425
|
+
const words = {}
|
|
1426
|
+
const layers = {}
|
|
1427
|
+
for (const dimension of ROW_DIMENSIONS) {
|
|
1428
|
+
// The session column states this object's own record: an entry of its own, or `inherit`
|
|
1429
|
+
// when the record wrote none. A dimension this entry wrote belongs to this address, so the
|
|
1430
|
+
// receipt states it as this row's word and names no layer — naming the server there would
|
|
1431
|
+
// have the receipt owe a dimension the value column says this row holds (§8.1b), while the
|
|
1432
|
+
// runtime precedence of a closed server belongs to behaviour and does not reach the page.
|
|
1433
|
+
if (own[dimension] !== undefined) {
|
|
1434
|
+
words[dimension] = own[dimension]
|
|
1435
|
+
continue
|
|
1436
|
+
}
|
|
1437
|
+
// A server that wrote this dimension answers the member's at runtime, so the receipt names
|
|
1438
|
+
// it; a server that wrote `automatic` deleted that dimension from its rule and decides no
|
|
1439
|
+
// member, which is why the receipt names it only for a word the rule actually holds.
|
|
1440
|
+
if (capability.kind !== 'server' && server[dimension] !== undefined && server[dimension] !== 'automatic') {
|
|
1441
|
+
layers[dimension] = serverId
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return { words, layers }
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* The session address's value for one object: the record's own words.
|
|
1449
|
+
*
|
|
1450
|
+
* Both columns state what their own record wrote, so a recorded `automatic` reads
|
|
1451
|
+
* `automatic` here — the word the record holds — and `inherit` is this column's word for
|
|
1452
|
+
* an address that recorded nothing, so no cell is blank. What those words would resolve
|
|
1453
|
+
* to is the runtime's answer and belongs to behaviour, not to this page: no resolved
|
|
1454
|
+
* value is put on screen. A dimension a server-level entry decided states that entry's
|
|
1455
|
+
* word and the receipt names the layer, which is where a delegation is readable.
|
|
1456
|
+
*/
|
|
1457
|
+
function sessionChoice(capability, statement) {
|
|
1458
|
+
const { words } = sessionWords(statement, capability)
|
|
1459
|
+
return { availability: words.availability ?? 'inherit', disclosure: words.disclosure ?? 'inherit' }
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* The identity, name, category, description and proved source of one declared
|
|
1464
|
+
* capability.
|
|
1465
|
+
*
|
|
1466
|
+
* The panel's rows and the settings page's entries state the same supply facts,
|
|
1467
|
+
* so they take them from one place instead of two derivations that could drift.
|
|
1468
|
+
*/
|
|
1469
|
+
function capabilityEntry(capability, presentation) {
|
|
1470
|
+
const display = presentation.get(capability.id)
|
|
1471
|
+
return {
|
|
1472
|
+
id: capability.id,
|
|
1473
|
+
name: display?.name ?? capability.name ?? capability.id,
|
|
1474
|
+
category: capability.category,
|
|
1475
|
+
description: capability.description,
|
|
1476
|
+
// Without a display entry the identity is stated by the namespace it belongs to:
|
|
1477
|
+
// a resource row is MCP's, a Skill row is a Skill's, and a tool row is the Host
|
|
1478
|
+
// registry's. A category is not a source, so it cannot stand in for one here.
|
|
1479
|
+
source: display?.source
|
|
1480
|
+
?? (capability.mcpServer === undefined
|
|
1481
|
+
? { kind: capability.category === 'skill' ? 'skill' : capability.category === 'resource' ? 'mcp' : 'host' }
|
|
1482
|
+
: { kind: 'mcp', server: capability.mcpServer }),
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* v2 rows: one per declared capability, one per declared MCP server.
|
|
1488
|
+
*
|
|
1489
|
+
* `description` is the Host's own one-line contract for the capability it declared.
|
|
1490
|
+
* The panel states seven columns and none of them is this text; the `/capabilities
|
|
1491
|
+
* list` command still names what it found, and taking that line from the same row
|
|
1492
|
+
* keeps one supply read behind both surfaces.
|
|
1493
|
+
*/
|
|
1494
|
+
function capabilityRows(capabilities, presentation, configuration, statement, proved) {
|
|
1495
|
+
const rows = capabilities.map(capability => {
|
|
1496
|
+
const { words, layers } = sessionWords(statement, capability)
|
|
1497
|
+
return {
|
|
1498
|
+
...capabilityEntry(capability, presentation),
|
|
1499
|
+
kind: 'capability',
|
|
1500
|
+
saved: savedChoice(configuration, capability),
|
|
1501
|
+
session: sessionChoice(capability, statement),
|
|
1502
|
+
// What the record wrote, in its own words, and the layer that wrote it: the session and
|
|
1503
|
+
// receipt columns state these words, and the receipt names the layer that wrote them.
|
|
1504
|
+
record: { availability: words.availability, disclosure: words.disclosure,
|
|
1505
|
+
layers: { availability: layers.availability ?? null, disclosure: layers.disclosure ?? null } },
|
|
1506
|
+
evidence: proved.has(capability.id),
|
|
1507
|
+
}
|
|
1508
|
+
})
|
|
1509
|
+
for (const server of new Set(capabilities.map(item => item.mcpServer).filter(Boolean))) {
|
|
1510
|
+
const id = `mcpServers.${server}`
|
|
1511
|
+
const row = { id, kind: 'server', name: server, category: 'server', source: { kind: 'mcp', server } }
|
|
1512
|
+
const { words, layers } = sessionWords(statement, row)
|
|
1513
|
+
rows.push({
|
|
1514
|
+
...row,
|
|
1515
|
+
saved: savedChoice(configuration, row),
|
|
1516
|
+
session: sessionChoice(row, statement),
|
|
1517
|
+
record: { availability: words.availability, disclosure: words.disclosure,
|
|
1518
|
+
layers: { availability: layers.availability ?? null, disclosure: layers.disclosure ?? null } },
|
|
1519
|
+
// A server is not a capability: no execution can obtain it as a whole.
|
|
1520
|
+
evidence: false,
|
|
1521
|
+
})
|
|
1522
|
+
}
|
|
1523
|
+
return rows
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** v2 rows: one per supplied binding entry. A withheld entry has no row. */
|
|
1527
|
+
async function bindingRows(events) {
|
|
1528
|
+
const supply = typeof readUserBindings === 'function' ? await readUserBindings() : { entries: [] }
|
|
1529
|
+
if (supply === null || typeof supply !== 'object' || !Array.isArray(supply.entries) || supply.error !== undefined) {
|
|
1530
|
+
// A document this build cannot parse is a failed read, not an empty catalogue:
|
|
1531
|
+
// an empty table would tell the user they have no bindings.
|
|
1532
|
+
throw new Error('ptc-plus: the binding catalogue could not be read')
|
|
1533
|
+
}
|
|
1534
|
+
const adoption = bindingAdoptionOf(events)
|
|
1535
|
+
const declined = adoption.state === 'decided' ? new Set(adoption.adoption.declined) : new Set()
|
|
1536
|
+
return supply.entries.filter(entry => entry.enabled === true).map(entry => ({
|
|
1537
|
+
id: entry.id,
|
|
1538
|
+
kind: 'binding',
|
|
1539
|
+
name: entry.name ?? entry.id,
|
|
1540
|
+
category: 'binding',
|
|
1541
|
+
source: { kind: 'bindings' },
|
|
1542
|
+
// The bindings document is a binding's saved address: an entry is listed only
|
|
1543
|
+
// while it is enabled, and its model-context preference decides whether the
|
|
1544
|
+
// model reads the declaration from the first request or only the callable
|
|
1545
|
+
// contract on demand.
|
|
1546
|
+
saved: { availability: 'enabled',
|
|
1547
|
+
disclosure: bindingModelPreferences(entry.modelContext).includeDeclaration ? 'full' : 'on-demand' },
|
|
1548
|
+
// The adoption record names the entries this session declined, so an entry it
|
|
1549
|
+
// does not name has no receipt at that address.
|
|
1550
|
+
session: { use: adoption.state === 'unreadable' ? 'damaged' : declined.has(entry.id) ? 'skip' : 'use',
|
|
1551
|
+
recorded: adoption.state === 'unreadable' || declined.has(entry.id) },
|
|
1552
|
+
evidence: false,
|
|
1553
|
+
}))
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* This session's own log, reached through whichever public handle the Host has.
|
|
1558
|
+
*
|
|
1559
|
+
* A live session object, the agent that owns it, and the persistence query all
|
|
1560
|
+
* name the same log. None of them is a capability source: which handle answered
|
|
1561
|
+
* changes no rendered value, only whether the read had to wait for a query.
|
|
1562
|
+
*/
|
|
1563
|
+
async function sessionLogOf(sessionId, signal) {
|
|
1564
|
+
const owned = typeof ctx.get === 'function' ? ctx.get('sessions')?.get?.(sessionId) : ctx.sessions?.get?.(sessionId)
|
|
1565
|
+
const holder = liveAgentFor(sessionId)
|
|
1566
|
+
const session = owned ?? holder?.session
|
|
1567
|
+
const query = sessionQueryService()
|
|
1568
|
+
// A Host without a projection service still has the session's own events.
|
|
1569
|
+
if (session !== undefined && session !== null && query === undefined && typeof session.snapshotEvents === 'function') {
|
|
1570
|
+
return { id: session.id, header: session.header, events: session.snapshotEvents() }
|
|
1571
|
+
}
|
|
1572
|
+
if (session !== undefined && session !== null) return readSessionLog(session, query, signal)
|
|
1573
|
+
return readSessionLog({ id: sessionId }, query, signal)
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* The live agent that holds one session's scope, when the Host still holds it.
|
|
1578
|
+
*
|
|
1579
|
+
* The agent is a scope handle and nothing else here: it names the declaration the
|
|
1580
|
+
* supply read observes. This lookup never asks whether the session is usable, runs
|
|
1581
|
+
* no maintenance branch and writes nothing — a session the Host no longer holds
|
|
1582
|
+
* simply has no scope left to name.
|
|
1583
|
+
*/
|
|
1584
|
+
function liveAgentFor(sessionId) {
|
|
1585
|
+
return (ctx.agents?.list?.() ?? []).find(agent => String(agent.session?.id ?? agent.id) === sessionId)
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* One session catalogue's two sources: this session's own log and the supply it was
|
|
1590
|
+
* declared.
|
|
1591
|
+
*
|
|
1592
|
+
* The supply is read in the session's own scope while the Host still holds that
|
|
1593
|
+
* scope, because the Host's own plane is a different question and answers fewer rows
|
|
1594
|
+
* than the session had. Once the scope is gone, the face this session's recorded
|
|
1595
|
+
* plan froze is the only statement of what it was supplied. A session that recorded
|
|
1596
|
+
* no plan, or one whose plan was composed while no capability feature required an
|
|
1597
|
+
* inventory, has no supply this read can state, so the read fails and names the
|
|
1598
|
+
* record it needed instead of answering with a face the session never had. A failed
|
|
1599
|
+
* source fails the whole read; a partial answer would be a screen that silently
|
|
1600
|
+
* misses what exists.
|
|
1601
|
+
*/
|
|
1602
|
+
async function sessionCatalogSupply(sessionId, signal) {
|
|
1603
|
+
signal?.throwIfAborted()
|
|
1604
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('ptc-plus: a capability read needs a session')
|
|
1605
|
+
let log
|
|
1606
|
+
try {
|
|
1607
|
+
log = await sessionLogOf(sessionId, signal)
|
|
1608
|
+
} catch (cause) {
|
|
1609
|
+
signal?.throwIfAborted()
|
|
1610
|
+
throw new Error('ptc-plus: the session catalogue needs this session\'s log', { cause })
|
|
1611
|
+
}
|
|
1612
|
+
if (log === null || typeof log !== 'object' || log.header === undefined) {
|
|
1613
|
+
throw new Error('ptc-plus: the session catalogue needs this session\'s header')
|
|
1614
|
+
}
|
|
1615
|
+
const events = Array.isArray(log.events) ? log.events : []
|
|
1616
|
+
const cwd = typeof log.header.cwd === 'string' ? log.header.cwd : undefined
|
|
1617
|
+
const agent = liveAgentFor(sessionId)
|
|
1618
|
+
signal?.throwIfAborted()
|
|
1619
|
+
let supply
|
|
1620
|
+
if (agent !== undefined) supply = await hostSupply({ agent, cwd }, signal)
|
|
1621
|
+
else {
|
|
1622
|
+
const recorded = recordedPlan(events, sessionId)
|
|
1623
|
+
if (recorded === undefined) {
|
|
1624
|
+
throw new Error('ptc-plus: this session recorded no capability plan; a session with no live scope states the face its record froze')
|
|
1625
|
+
}
|
|
1626
|
+
const snapshot = sourcePlan(recorded, sessionId)
|
|
1627
|
+
// A composition answers an empty `capabilities` two ways, and only one of them is a
|
|
1628
|
+
// face. When an inventory was required the array is the face, empty or not. When none
|
|
1629
|
+
// was — no capability feature was on — nothing was supplied to the model, so the record
|
|
1630
|
+
// states no face at all and an empty table would claim the Host declared nothing.
|
|
1631
|
+
if (!requiresCapabilityInventory(snapshot.settings)) {
|
|
1632
|
+
throw new Error('ptc-plus: this session\'s recorded plan froze no capability inventory; a session with no live scope states the face its record froze, and this record holds none')
|
|
1633
|
+
}
|
|
1634
|
+
supply = frozenSupply(snapshot)
|
|
1635
|
+
}
|
|
1636
|
+
signal?.throwIfAborted()
|
|
1637
|
+
return { events, supply }
|
|
900
1638
|
}
|
|
901
1639
|
|
|
902
1640
|
return Object.freeze({
|
|
@@ -905,184 +1643,158 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
905
1643
|
stateFor,
|
|
906
1644
|
visibleSchemas,
|
|
907
1645
|
validateSupply,
|
|
908
|
-
|
|
1646
|
+
/**
|
|
1647
|
+
* Recompose this session's plan from the saved configuration and the record.
|
|
1648
|
+
*
|
|
1649
|
+
* `agent` is a parameter of the call, not a property of the session: which
|
|
1650
|
+
* live agent's composition to replace is what the caller states by naming it.
|
|
1651
|
+
* Nothing else about the session decides whether a refresh may happen.
|
|
1652
|
+
*/
|
|
1653
|
+
async refresh(agent, sections, signal) {
|
|
909
1654
|
signal?.throwIfAborted()
|
|
1655
|
+
if (agent === undefined) throw new TypeError('ptc-plus: a capability refresh needs an agent')
|
|
910
1656
|
if (typeof agent.runMaintenance !== 'function') throw new Error('ptc-plus: capability refresh requires the public agent maintenance boundary')
|
|
911
1657
|
return agent.runMaintenance(async maintenanceSignal => {
|
|
912
|
-
|
|
1658
|
+
// The Host's declaration is observed first: a refresh composes from it, so a
|
|
1659
|
+
// composition that had to guess it would compose from less than the Host says.
|
|
913
1660
|
const assembly = await ctx.systemPrompt.assemble({ agent, scope: agent, signal: maintenanceSignal })
|
|
914
1661
|
signal?.throwIfAborted()
|
|
915
1662
|
maintenanceSignal.throwIfAborted()
|
|
916
1663
|
const preparationSignal = AbortSignal.any([signal, maintenanceSignal, agent.signal].filter(Boolean))
|
|
917
|
-
|
|
918
|
-
return prepare(agent, stateFor(agent)?.hostSections ?? assembly.sections, true, preparationSignal,
|
|
919
|
-
commandId === undefined ? undefined : {
|
|
920
|
-
sourceSettings: capabilityConfiguration(currentConfig), sessionOverrides: previous?.sessionOverrides ?? {}, commandId,
|
|
921
|
-
})
|
|
1664
|
+
return prepare(agent, sections ?? stateFor(agent)?.hostSections ?? assembly.sections, true, preparationSignal)
|
|
922
1665
|
})
|
|
923
1666
|
},
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const recorded = recordedPlan(history, String(agent.session.id ?? agent.id))
|
|
934
|
-
const snapshot = stateFor(agent)?.snapshot ?? (recorded && sourcePlan(recorded, String(agent.session.id ?? agent.id)))
|
|
935
|
-
const state = snapshot ? { snapshot } : undefined
|
|
936
|
-
const sourceSettings = snapshot?.sourceSettings ?? snapshot?.settings
|
|
937
|
-
const sourceOverrides = sourceSettings?.manual?.overrides
|
|
938
|
-
const sessionSourceSelection = id => sourceOverrides?.[id] ?? (sourceOverrides ? 'automatic' : 'unknown')
|
|
939
|
-
// A paused or unmanaged session has no frozen allocation to report: every
|
|
940
|
-
// row then states the one truth that applies, the Host default. Only a
|
|
941
|
-
// managed session may report a pending refresh boundary.
|
|
942
|
-
const planManaged = state !== undefined && effectiveCapabilitySettings(state.snapshot.settings).enabled === true
|
|
943
|
-
const management = state === undefined ? 'unknown'
|
|
944
|
-
: state.snapshot.settings.contextEnabled === false ? 'paused'
|
|
945
|
-
: planManaged ? 'managed' : 'off'
|
|
946
|
-
const dimensions = capability => {
|
|
947
|
-
const saved = capabilityConfiguration(currentConfig)
|
|
948
|
-
if (!saved.policy && !snapshot?.settings.policy) return { managementEnabled: snapshot && effectiveCapabilitySettings(snapshot.settings).enabled }
|
|
949
|
-
const source = sourceSettings?.policy ? capabilityRule(sourceSettings.policy, capability) : undefined
|
|
950
|
-
const temporary = snapshot?.sessionOverrides?.[capability.id]
|
|
951
|
-
const rule = rules => capability.id.startsWith('mcpServers.') ? rules.mcpServers[capability.id.slice(11)] ?? {} : rules.items[capability.id] ?? {}
|
|
952
|
-
return { managementEnabled: snapshot && effectiveCapabilitySettings(snapshot.settings).enabled, ...(saved.policy ? { savedRule: rule(saved.policy) } : {}),
|
|
953
|
-
...(sourceSettings?.policy ? { sourceRule: rule(sourceSettings.policy), sourceDimensions: source } : {}),
|
|
954
|
-
temporaryRule: temporaryCapabilityRule(temporary),
|
|
955
|
-
...(snapshot?.settings.policy ? { effectiveDimensions: snapshot.plan.entries.find(entry => entry.id === capability.id)
|
|
956
|
-
?? (capability.category === 'server' ? effectiveCapabilitySettings(snapshot.settings).enabled
|
|
957
|
-
? capabilityRule(snapshot.settings.policy, capability) : { availability: 'enabled', disclosure: 'host', availabilitySource: 'host', disclosureSource: 'host' } : undefined) } : {}) }
|
|
958
|
-
}
|
|
959
|
-
const entries = capabilities.map(capability => ({ id: capability.id, category: capability.category,
|
|
960
|
-
description: capability.description, ...presentation.get(capability.id), ...dimensions(capability),
|
|
961
|
-
...(capability.mcpServer === undefined ? {} : { mcpServer: capability.mcpServer }),
|
|
962
|
-
saved: capabilityConfiguration(currentConfig).manual.overrides[capability.id] ?? 'automatic',
|
|
963
|
-
sessionSourceSelection: sessionSourceSelection(capability.id),
|
|
964
|
-
temporary: typeof state?.snapshot.sessionOverrides?.[capability.id] === 'object' ? 'inherit' : state?.snapshot.sessionOverrides?.[capability.id] ?? 'inherit',
|
|
965
|
-
effective: state === undefined ? 'pending'
|
|
966
|
-
: planManaged ? state.snapshot.plan.entries.find(entry => entry.id === capability.id)?.selection ?? 'pending'
|
|
967
|
-
: 'host' }))
|
|
968
|
-
for (const server of new Set(capabilities.map(item => item.mcpServer).filter(Boolean))) {
|
|
969
|
-
const id = `mcpServers.${server}`
|
|
970
|
-
const enclosingClosure = state ? effectiveCapabilitySettings(state.snapshot.settings).enabled && (state.snapshot.settings.policy
|
|
971
|
-
? state.snapshot.settings.policy.mcpServers[server]?.availability === 'closed'
|
|
972
|
-
: state.snapshot.settings.manual.overrides[id] === 'closed') : undefined
|
|
973
|
-
const effective = state === undefined ? 'pending'
|
|
974
|
-
: !planManaged ? 'host'
|
|
975
|
-
: state.snapshot.settings.policy
|
|
976
|
-
? capabilityRule(state.snapshot.settings.policy, { id, category: 'server', mcpServer: server }).availability
|
|
977
|
-
: enclosingClosure ? 'closed' : 'enabled'
|
|
978
|
-
entries.push({ id, category: 'server', name: server, description: `MCP server ${server}`,
|
|
979
|
-
source: { kind: 'mcp', server }, ...dimensions({ id, category: 'server', mcpServer: server }), saved: capabilityConfiguration(currentConfig).manual.overrides[id] ?? 'automatic',
|
|
980
|
-
sessionSourceSelection: sessionSourceSelection(id),
|
|
981
|
-
temporary: typeof state?.snapshot.sessionOverrides?.[id] === 'object' ? 'inherit' : state?.snapshot.sessionOverrides?.[id] ?? 'inherit',
|
|
982
|
-
enclosingClosure, effective })
|
|
983
|
-
}
|
|
984
|
-
return { management, entries }
|
|
985
|
-
},
|
|
986
|
-
async catalogFor(agent, signal) {
|
|
987
|
-
return (await this.catalogObservation(agent, signal)).entries
|
|
988
|
-
},
|
|
1667
|
+
/**
|
|
1668
|
+
* State one capability choice: one intent, one writer, one receipt.
|
|
1669
|
+
*
|
|
1670
|
+
* A session choice is a record and a global choice is a saved setting; neither
|
|
1671
|
+
* is a plan. The plan is the runtime's composition of the record and is
|
|
1672
|
+
* rebuilt by the entry that composes one, so no control here asks whether a
|
|
1673
|
+
* runtime exists — a decision this session made is the same decision whether
|
|
1674
|
+
* or not something is running to act on it.
|
|
1675
|
+
*/
|
|
989
1676
|
async change(agent, intent, signal, globalSettings) {
|
|
990
|
-
if (!canRefresh(agent)) throw new Error('ptc-plus: select an active PTC session')
|
|
991
1677
|
const dimension = intent.dimension
|
|
992
|
-
const
|
|
1678
|
+
const selections = dimension === 'availability' ? ['enabled', 'closed', 'automatic', 'inherit']
|
|
993
1679
|
: dimension === 'disclosure' ? [...CAPABILITY_DISCLOSURES, 'automatic', 'inherit']
|
|
994
1680
|
: dimension === undefined ? ['full', 'on-demand', 'closed', 'automatic', 'inherit'] : []
|
|
995
|
-
if (!['session', 'global'].includes(intent.scope) || !
|
|
1681
|
+
if (!['session', 'global'].includes(intent.scope) || !selections.includes(intent.selection)
|
|
996
1682
|
|| intent.scope === 'global' && intent.selection === 'inherit') throw new TypeError('invalid capability change')
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
globalSaved = true
|
|
1052
|
-
}
|
|
1053
|
-
try {
|
|
1054
|
-
if (globalSaved) {
|
|
1055
|
-
const saved = globalSettings.read()
|
|
1056
|
-
if (!saved?.value || !Number.isSafeInteger(saved.revision)) throw new Error('ptc-plus: saved settings could not be confirmed for this session')
|
|
1057
|
-
sourceSettings = capabilityConfiguration({ ...currentConfig, ...saved.value })
|
|
1058
|
-
if (dimension) {
|
|
1059
|
-
const retained = temporaryCapabilityRule(sessionOverrides[intent.id]); delete retained[dimension]
|
|
1060
|
-
if (Object.keys(retained).length) sessionOverrides[intent.id] = retained
|
|
1061
|
-
else delete sessionOverrides[intent.id]
|
|
1062
|
-
} else delete sessionOverrides[intent.id]
|
|
1063
|
-
}
|
|
1064
|
-
combined.throwIfAborted()
|
|
1065
|
-
if (!effectiveCapabilitySettings(sourceSettings).enabled) throw new Error('ptc-plus: enable capability management before changing a session choice')
|
|
1066
|
-
await prepare(agent, assembly.sections, true, combined, { sourceSettings, sessionOverrides, commandId: intent.commandId })
|
|
1067
|
-
return { globalSaved, sessionApplied: true, publication: planCommitted(agent, stateFor(agent).message) ? 'accepted' : 'pending' }
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
if (globalSaved) return { globalSaved: true, sessionApplied: false, error: String(error?.message ?? error) }
|
|
1070
|
-
throw error
|
|
1071
|
-
}
|
|
1072
|
-
})
|
|
1683
|
+
// The record vocabulary is asked once, here, before anything is appended: an id it
|
|
1684
|
+
// cannot name would otherwise reach the message writer and fail there as an
|
|
1685
|
+
// internal error, after the command had already been answered.
|
|
1686
|
+
if (typeof intent.id !== 'string' || !CAPABILITY_ENTRY_ID.test(intent.id)) {
|
|
1687
|
+
throw new TypeError('ptc-plus: a capability choice names an entry as tools.<name>, skills.<name>, mcp.<server> or mcpServers.<server>')
|
|
1688
|
+
}
|
|
1689
|
+
// A server has no disclosure of its own: its members carry one. This states
|
|
1690
|
+
// the vocabulary, not what any process happens to supply.
|
|
1691
|
+
if (!dimension && intent.id.startsWith('mcpServers.') && !['closed', 'automatic', 'inherit'].includes(intent.selection)) {
|
|
1692
|
+
throw new Error('ptc-plus: MCP server choices are closed, automatic or inherit')
|
|
1693
|
+
}
|
|
1694
|
+
if (intent.scope === 'global') {
|
|
1695
|
+
if (!globalSettings?.read || !globalSettings?.update) throw new Error('ptc-plus: global capability changes require the public settings writer')
|
|
1696
|
+
const before = globalSettings.read()
|
|
1697
|
+
if (!before || !Number.isSafeInteger(before.revision)) throw new Error('ptc-plus: global capability changes require settings revision evidence')
|
|
1698
|
+
const overrides = JSON.parse(before.value.capabilityOverrides ?? '{}')
|
|
1699
|
+
if (intent.selection === 'automatic') delete overrides[intent.id]
|
|
1700
|
+
else overrides[intent.id] = intent.selection
|
|
1701
|
+
signal?.throwIfAborted()
|
|
1702
|
+
// The settings document this writer reads holds the record: a document carrying a policy
|
|
1703
|
+
// is composed into verbatim. Only a document that carries none starts from the record's
|
|
1704
|
+
// own layers, so the synthesized bottom layer is never frozen into the record (§8.1c).
|
|
1705
|
+
const policy = before.value.capabilityPolicy !== undefined
|
|
1706
|
+
? capabilityPolicyForConfig(before.value) : capabilityPolicyFromRecord(before.value)
|
|
1707
|
+
let nextPolicy = dimension ? changeCapabilityRule(policy, intent.id, dimension, intent.selection)
|
|
1708
|
+
: changeCapabilityRule(policy, intent.id, 'availability', intent.selection === 'closed' ? 'closed'
|
|
1709
|
+
: intent.selection === 'automatic' ? 'automatic' : 'enabled')
|
|
1710
|
+
if (!dimension) nextPolicy = intent.selection === 'automatic' ? changeCapabilityRule(nextPolicy, intent.id, 'disclosure', 'automatic')
|
|
1711
|
+
: intent.selection === 'closed' ? nextPolicy : changeCapabilityRule(nextPolicy, intent.id, 'disclosure', intent.selection)
|
|
1712
|
+
const patch = before.value.capabilityPolicy || dimension
|
|
1713
|
+
? { capabilityPolicy: JSON.stringify(nextPolicy), ...(!dimension ? { capabilityOverrides: JSON.stringify(overrides) } : {}) }
|
|
1714
|
+
: { capabilityOverrides: JSON.stringify(overrides) }
|
|
1715
|
+
await globalSettings.update(patch, before.revision)
|
|
1716
|
+
return { globalSaved: true, sessionApplied: true, publication: 'recorded' }
|
|
1717
|
+
}
|
|
1718
|
+
// A statement is the whole of what this session decided, so composing the next
|
|
1719
|
+
// one starts from the log that holds the current statement — the same log the
|
|
1720
|
+
// panel reads. A stale or empty runtime view would silently restate the choice
|
|
1721
|
+
// just made and withdraw every decision recorded before it.
|
|
1722
|
+
const statement = capabilitySelectionOf(await observeHistory(agent, signal), String(agent.session.id ?? agent.id))
|
|
1723
|
+
signal?.throwIfAborted()
|
|
1724
|
+
const choices = { ...(statement.state === 'decided' ? statement.selection.choices : {}) }
|
|
1725
|
+
const patch = { ...(choices[intent.id] ?? {}) }
|
|
1726
|
+
if (dimension) {
|
|
1727
|
+
if (intent.selection === 'inherit') delete patch[dimension]
|
|
1728
|
+
else patch[dimension] = intent.selection
|
|
1729
|
+
} else if (intent.selection === 'inherit') {
|
|
1730
|
+
delete patch.availability; delete patch.disclosure
|
|
1731
|
+
} else if (intent.selection === 'closed') patch.availability = 'closed'
|
|
1732
|
+
else Object.assign(patch, capabilityRuleOfSelection(intent.selection))
|
|
1733
|
+
if (Object.keys(patch).length > 0) choices[intent.id] = patch
|
|
1734
|
+
else delete choices[intent.id]
|
|
1735
|
+
const publication = recordSelection(agent, choices, signal)
|
|
1736
|
+
return { globalSaved: false, sessionApplied: true, publication }
|
|
1073
1737
|
},
|
|
1738
|
+
/**
|
|
1739
|
+
* The composition this session built but has not delivered yet.
|
|
1740
|
+
*
|
|
1741
|
+
* A command that composed a plan before the Host accepted a step has to carry it
|
|
1742
|
+
* in its receipt: the plan is not in the log yet, so a restart would otherwise
|
|
1743
|
+
* have no way to know which composition the command made.
|
|
1744
|
+
*/
|
|
1074
1745
|
pendingChoiceMessage(agent) {
|
|
1075
1746
|
const state = stateFor(agent)
|
|
1076
|
-
return
|
|
1747
|
+
return state?.message !== undefined && !planCommitted(agent, state.message) ? state.message : undefined
|
|
1077
1748
|
},
|
|
1078
|
-
|
|
1749
|
+
/**
|
|
1750
|
+
* The sections one assembly delivers.
|
|
1751
|
+
*
|
|
1752
|
+
* `delivered` is the step's admitted definitions, as in `sdk`: the declaration, the
|
|
1753
|
+
* program members and the explorable metadata all state that set, so guidance that
|
|
1754
|
+
* instructs a capability must state it too. A composition is not the admission — it
|
|
1755
|
+
* is composed once and reused until the session decides something else — so a
|
|
1756
|
+
* section whose definition the step withholds would otherwise stay after the
|
|
1757
|
+
* capability it describes has left every other surface.
|
|
1758
|
+
*
|
|
1759
|
+
* The resource directory follows the same answer through the rows it names: its servers
|
|
1760
|
+
* are the argument of the three resource operations, so a row that states no delivered
|
|
1761
|
+
* operation is no row of this step and the directory lists no server for it.
|
|
1762
|
+
*/
|
|
1763
|
+
projectSections(agent, sections, delivered = undefined) {
|
|
1079
1764
|
const state = stateFor(agent)
|
|
1080
|
-
|
|
1765
|
+
const decisions = decisionsFor(agent)
|
|
1766
|
+
const admitted = delivered === undefined ? undefined : new Set(delivered.map(schema => schema.name))
|
|
1767
|
+
// The guidance attached to a capability the session closed goes with the capability,
|
|
1768
|
+
// whether or not a composition states the rest of the environment. Dependency
|
|
1769
|
+
// sections are named after what they belong to, so the record decides them directly.
|
|
1770
|
+
// `tool:<name>` names a capability of this step, so the admission decides it as well;
|
|
1771
|
+
// the two transports are the call channel rather than capabilities and stay exempt.
|
|
1772
|
+
const declared = sections.filter(section => {
|
|
1773
|
+
const name = section.name
|
|
1774
|
+
if (typeof name !== 'string') return true
|
|
1775
|
+
if (name.startsWith('tool:')) {
|
|
1776
|
+
const member = name.slice('tool:'.length)
|
|
1777
|
+
if (TRANSPORTS.has(member)) return true
|
|
1778
|
+
if (admitted !== undefined && !admitted.has(member)) return false
|
|
1779
|
+
return !recordClosesMember(decisions, member)
|
|
1780
|
+
}
|
|
1781
|
+
if (name.startsWith('mcp:') && name !== 'mcp-resource-servers') return !decisions.servers.has(name.slice('mcp:'.length))
|
|
1782
|
+
return true
|
|
1783
|
+
})
|
|
1784
|
+
if (!state?.snapshot.plan.enabled) {
|
|
1785
|
+
return declared.flatMap(section => recordKeptServers(decisions, section,
|
|
1786
|
+
sharedPaths(state, admitted, decisions).operations.length !== 0))
|
|
1787
|
+
}
|
|
1788
|
+
const paths = sharedPaths(state, admitted, decisions)
|
|
1081
1789
|
const inventory = [...declaredCapabilities(state).values()]
|
|
1082
|
-
|
|
1790
|
+
// A resource row is explorable through the operations its contract names, so it is a
|
|
1791
|
+
// row of this step only while one of them is delivered; a Skill row keeps its own
|
|
1792
|
+
// answer because it may still state the user's path when the loader is withheld.
|
|
1793
|
+
const retained = inventory.filter(item => declaredSelection(state, item.id) === 'host' && supplied(state, item.id)
|
|
1794
|
+
&& (item.category !== CAPABILITY_INVENTORY_CATEGORIES.resource || paths.operations.length > 0))
|
|
1083
1795
|
const retainedSections = new Set(retained.flatMap(item => item.dependencies ?? []).map(item => item.id))
|
|
1084
1796
|
const ownedSections = new Set(inventory.flatMap(item => item.dependencies ?? []).map(item => item.id))
|
|
1085
|
-
return
|
|
1797
|
+
return declared.flatMap(section => {
|
|
1086
1798
|
if (section.name === 'mcp-resource-servers') {
|
|
1087
1799
|
const servers = retained.filter(item => item.category === 'resource').map(item => item.selector.server)
|
|
1088
1800
|
return servers.length ? [{ ...section, text: `Use the following MCP resource servers as the server argument: ${JSON.stringify(servers)}.` }] : []
|
|
@@ -1093,32 +1805,88 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1093
1805
|
return [section]
|
|
1094
1806
|
})
|
|
1095
1807
|
},
|
|
1096
|
-
|
|
1808
|
+
/**
|
|
1809
|
+
* The declarations one assembly delivers.
|
|
1810
|
+
*
|
|
1811
|
+
* `delivered` is the step's admitted definitions — what the Host rendered its own
|
|
1812
|
+
* `tools:sdk` block from and what it builds the program's `tools` members from. A
|
|
1813
|
+
* declaration computed from the registry view instead would name a member this step
|
|
1814
|
+
* does not deliver, which the program then cannot call. Absent an observation, the
|
|
1815
|
+
* registry view is the only evidence there is.
|
|
1816
|
+
*
|
|
1817
|
+
* The edit transport is read from the registry either way: it is this plugin's own
|
|
1818
|
+
* installed tool and stays declared in every managed render, including the step on
|
|
1819
|
+
* which it was installed — an admission resolved before that install cannot state it.
|
|
1820
|
+
*/
|
|
1821
|
+
sdk(agent, nativeSdk, delivered = undefined) {
|
|
1097
1822
|
const state = stateFor(agent)
|
|
1098
|
-
|
|
1823
|
+
const decisions = decisionsFor(agent)
|
|
1099
1824
|
const candidates = toolSchemasForAgent(agent)
|
|
1100
|
-
|
|
1101
|
-
|
|
1825
|
+
// The admission is a restriction of this view of the registry, so one registry read
|
|
1826
|
+
// answers both: which definitions exist, and which of them this step delivers.
|
|
1827
|
+
const admitted = delivered === undefined ? undefined : new Set(delivered.map(schema => schema.name))
|
|
1828
|
+
const reached = admitted === undefined ? candidates : candidates.filter(schema => admitted.has(schema.name))
|
|
1829
|
+
const declared = reached.filter(schema => !recordClosesMember(decisions, schema.name))
|
|
1830
|
+
const unchanged = declared.length === reached.length
|
|
1831
|
+
if (!state?.snapshot.plan.enabled) {
|
|
1832
|
+
// No composition to state the environment: the Host's own text is the delivered
|
|
1833
|
+
// declaration, and the record's closures are the only thing it may not carry.
|
|
1834
|
+
return unchanged ? nativeSdk : renderManagedSdk(candidates, declared)
|
|
1835
|
+
}
|
|
1836
|
+
const members = declared.filter(schema => !TRANSPORTS.has(schema.name))
|
|
1837
|
+
const host = members.filter(schema => declaredSelection(state, `tools.${schema.name}`) === 'host'
|
|
1102
1838
|
&& memberSupplied(state, schema.name))
|
|
1103
|
-
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
return renderToolsSdk(editTransport === undefined ? host : [...host, editTransport])
|
|
1839
|
+
// The edit transport is callable inside every program and is not part of the
|
|
1840
|
+
// managed disclosure, so its Host schema stays declared in every managed render.
|
|
1841
|
+
// The direct transport is never an SDK member.
|
|
1842
|
+
if (unchanged && host.length === members.length) return nativeSdk
|
|
1843
|
+
return renderManagedSdk(candidates, host)
|
|
1109
1844
|
},
|
|
1110
1845
|
projectMembers(agent, members) {
|
|
1111
1846
|
const state = stateFor(agent)
|
|
1112
|
-
|
|
1113
|
-
|
|
1847
|
+
const decisions = decisionsFor(agent)
|
|
1848
|
+
const declared = members.filter(name => !recordClosesMember(decisions, name))
|
|
1849
|
+
if (!state?.snapshot.plan.enabled) return declared
|
|
1850
|
+
return declared.filter(name => memberSupplied(state, name))
|
|
1114
1851
|
},
|
|
1115
|
-
|
|
1852
|
+
/**
|
|
1853
|
+
* The capabilities this session can explore.
|
|
1854
|
+
*
|
|
1855
|
+
* A row states an invocation path, and a composition states the one it was built under. A
|
|
1856
|
+
* decision recorded afterwards is already the session's newest statement — `change` writes a
|
|
1857
|
+
* record, never a plan — so a row whose path that decision took away may state only what
|
|
1858
|
+
* remains: a shared loader's Skill row keeps the user path or leaves entirely, and a
|
|
1859
|
+
* resource row states only the operations this session still delivers. Serving the frozen
|
|
1860
|
+
* path would hand the model a call the program no longer has, in the one window where the
|
|
1861
|
+
* declaration and the program members already answer the decision.
|
|
1862
|
+
*
|
|
1863
|
+
* `delivered` is the step's admitted definitions, as in `sdk` and `projectSections`, and it
|
|
1864
|
+
* asks the same question of the other input a composition cannot answer: a composition is
|
|
1865
|
+
* built once and reused while a step may deliver less — this session decided nothing, so
|
|
1866
|
+
* nothing recomposes — and every other projection already states that step. The shared
|
|
1867
|
+
* operations a row can name are exactly the ones this step delivers, so a row whose loader
|
|
1868
|
+
* or operation the step withholds states the path that remains instead of a call the
|
|
1869
|
+
* program lacks. Absent an observation the composition's answer stands: a caller with no
|
|
1870
|
+
* admission is not asking to state less than the session was composed from.
|
|
1871
|
+
*
|
|
1872
|
+
* A row only ever loses a path here. Absence of evidence is not an addition: an operation
|
|
1873
|
+
* the plan never froze is not one this composition stated, so no row starts stating it.
|
|
1874
|
+
*/
|
|
1875
|
+
metadata(agent, delivered = undefined) {
|
|
1116
1876
|
const state = stateFor(agent)
|
|
1117
1877
|
if (!state?.snapshot.plan.enabled) return []
|
|
1878
|
+
const policyLayer = Boolean(effectiveCapabilitySettings(state.snapshot.settings)?.policy)
|
|
1879
|
+
const admitted = delivered === undefined ? undefined : new Set(delivered.map(schema => schema.name))
|
|
1880
|
+
const paths = sharedPaths(state, admitted, decisionsFor(agent))
|
|
1118
1881
|
const capabilities = declaredCapabilities(state)
|
|
1119
1882
|
return ['skill', 'resource'].map(category => ({ namespace: category === 'skill' ? 'skills' : 'mcp',
|
|
1120
1883
|
members: [...capabilities.values()].filter(item => item.category === category && supplied(state, item.id))
|
|
1121
1884
|
.map(item => state.liveCapabilities?.get(item.id) ?? item)
|
|
1885
|
+
.flatMap(item => category === 'skill'
|
|
1886
|
+
? [paths.loader ? item : retainedSkillCapability(item, false)]
|
|
1887
|
+
: paths.operations.length === 0 ? []
|
|
1888
|
+
: [{ ...item, contract: `MCP resource server ${JSON.stringify(item.selector.server)}. ${resourceOperationNotice(paths.operations, policyLayer)}` }])
|
|
1889
|
+
.filter(item => item.effectiveAvailability !== 'closed')
|
|
1122
1890
|
.map(item => ({ name: item.id.slice(item.id.indexOf('.') + 1), description: item.description,
|
|
1123
1891
|
contract: item.contract, dependencies: item.dependencies ?? [], revision: item.revision, parameters: {}, returns: {},
|
|
1124
1892
|
...(item.invocation === undefined ? {} : { invocation: item.invocation }),
|
|
@@ -1152,7 +1920,6 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1152
1920
|
const decision = await next()
|
|
1153
1921
|
if (decision.kind !== 'enter' || payload.signal?.aborted) return decision
|
|
1154
1922
|
await observeHistory(payload.agent, payload.signal)
|
|
1155
|
-
await reconcilePendingChoice(payload.agent, payload.signal, true)
|
|
1156
1923
|
const state = stateFor(payload.agent)
|
|
1157
1924
|
if (decision.kind !== 'enter' || !state || payload.signal?.aborted) return decision
|
|
1158
1925
|
await statisticsOperation(() => store?.available(payload.agent, statisticsSnapshot(state)))
|
|
@@ -1240,7 +2007,11 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1240
2007
|
}
|
|
1241
2008
|
// Before task history exists, the initial plan precedes admitted task messages.
|
|
1242
2009
|
// Refresh and recovery append after committed history; they never reorder it.
|
|
1243
|
-
|
|
2010
|
+
// A PTC-owned record is this plugin's own statement, not task history: a session
|
|
2011
|
+
// that recorded a choice before its first step still has no task to follow.
|
|
2012
|
+
const initial = !sessionEvents(payload.agent.session).some(event => event.type === 'assistant/message'
|
|
2013
|
+
|| event.type === 'tool/result'
|
|
2014
|
+
|| event.type === 'user/message' && !isPtcMessageSource(event.data?.source))
|
|
1244
2015
|
return { ...decision, messages: initial ? [...additions, ...messages] : [...messages, ...additions] }
|
|
1245
2016
|
},
|
|
1246
2017
|
admitted(exec) {
|
|
@@ -1308,48 +2079,66 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1308
2079
|
},
|
|
1309
2080
|
describe(agent) {
|
|
1310
2081
|
const state = stateFor(agent)
|
|
1311
|
-
|
|
2082
|
+
// The session column is the record's answer, not the composition's: a
|
|
2083
|
+
// composition reports what it built, and this field reports what the session
|
|
2084
|
+
// decided. They agree once the composition has caught up, and until then only
|
|
2085
|
+
// this field is the answer.
|
|
2086
|
+
const sessionOverrides = sessionOverridesOf(capabilitySelectionOf(sessionEvents(agent?.session), agent?.session === undefined ? undefined : String(agent.session.id ?? agent.id)))
|
|
2087
|
+
return state === undefined ? { configured: capabilityConfiguration(currentConfig), effective: null, sessionOverrides }
|
|
1312
2088
|
: { configured: capabilityConfiguration(currentConfig), effective: state.snapshot.plan,
|
|
1313
2089
|
publication: state.message === undefined ? 'not-required' : planCommitted(agent, state.message) ? 'accepted' : 'pending',
|
|
1314
2090
|
sourceSettings: state.snapshot.sourceSettings ?? state.snapshot.settings,
|
|
1315
|
-
sessionOverrides
|
|
2091
|
+
sessionOverrides,
|
|
1316
2092
|
pendingRefresh: digest(capabilityConfiguration(currentConfig)) !== digest(state.snapshot.sourceSettings ?? state.snapshot.settings) }
|
|
1317
2093
|
},
|
|
1318
2094
|
describeSessions() {
|
|
1319
2095
|
return [...sessions].map(([sessionId, state]) => ({ sessionId, effective: state.snapshot.plan,
|
|
1320
2096
|
publication: state.message === undefined ? 'not-required' : planCommitted({ session: state.session }, state.message) ? 'accepted' : 'pending',
|
|
1321
2097
|
configuration: savedConfiguration(state.snapshot.sourceSettings ?? state.snapshot.settings),
|
|
1322
|
-
sessionOverrides: state.
|
|
2098
|
+
sessionOverrides: sessionOverridesOf(capabilitySelectionOf(sessionEvents(state.session), sessionId)),
|
|
1323
2099
|
pendingRefresh: digest(capabilityConfiguration(currentConfig)) !== digest(state.snapshot.sourceSettings ?? state.snapshot.settings) }))
|
|
1324
2100
|
},
|
|
1325
|
-
|
|
2101
|
+
/**
|
|
2102
|
+
* The live sessions a refresh could be requested for.
|
|
2103
|
+
*
|
|
2104
|
+
* A refresh replaces one live agent's composition, so the agent is the only
|
|
2105
|
+
* fact a target has to supply. Whether a session's model tool table declares
|
|
2106
|
+
* the direct transport answers a different question — what that session can
|
|
2107
|
+
* call — and a target list is not the place to answer it.
|
|
2108
|
+
*/
|
|
1326
2109
|
describeTargets() {
|
|
1327
|
-
return (ctx.agents?.list?.() ?? []).
|
|
1328
|
-
.map(agent => ({ sessionId: String(agent.session.id ?? agent.id) }))
|
|
2110
|
+
return (ctx.agents?.list?.() ?? []).map(agent => ({ sessionId: String(agent.session.id ?? agent.id) }))
|
|
1329
2111
|
},
|
|
1330
2112
|
async describeSessionCatalog({ sessionId } = {}, signal) {
|
|
2113
|
+
// Three permanent sources, no fourth. The session's own log is the record, the
|
|
2114
|
+
// supply of this session's own plane is the declared face, and the saved
|
|
2115
|
+
// configuration is the saved column. A request that names no session states the
|
|
2116
|
+
// Host's own declaration — the process-scope object set, not a session's full
|
|
2117
|
+
// declared face, which also carries a preset's agent-scope entries (capability-surface
|
|
2118
|
+
// §8.9) — and no session record exists to state, so those columns read `inherit` and
|
|
2119
|
+
// no binding adoption is drawn from a log this read was not given.
|
|
2120
|
+
const named = typeof sessionId === 'string' && sessionId.length > 0
|
|
2121
|
+
const { events, supply } = named ? await sessionCatalogSupply(sessionId, signal)
|
|
2122
|
+
: { events: [], supply: await hostSupply({}, signal) }
|
|
2123
|
+
const configuration = capabilityConfiguration(currentConfig)
|
|
2124
|
+
const rows = capabilityRows(supply.capabilities, supply.presentation, configuration,
|
|
2125
|
+
capabilitySelectionOf(events, sessionId), capabilityProofOf(events))
|
|
2126
|
+
const bindingsEnabled = currentConfig.userBindingsEnabled === true
|
|
2127
|
+
if (bindingsEnabled && named) rows.push(...await bindingRows(events))
|
|
1331
2128
|
signal?.throwIfAborted()
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
signal?.throwIfAborted()
|
|
1338
|
-
if (!(ctx.agents?.list?.() ?? []).includes(agent) || !canRefresh(agent)
|
|
1339
|
-
|| String(agent.session.id ?? agent.id) !== sessionId) {
|
|
1340
|
-
throw new Error('ptc-plus: capability observation target is no longer available')
|
|
1341
|
-
}
|
|
1342
|
-
const savedSettings = capabilityConfiguration(currentConfig)
|
|
1343
|
-
// The saved group and management switches gate session writes in change();
|
|
1344
|
-
// report them so the Client can block staged choices with the reason.
|
|
1345
|
-
return { schema: 'ptc-capability-session-catalog/v1', management, entries,
|
|
1346
|
-
savedSettings: { enabled: effectiveCapabilitySettings(savedSettings).enabled === true,
|
|
1347
|
-
contextEnabled: savedSettings.contextEnabled !== false } }
|
|
2129
|
+
// No `live`, no `scope`, no `plan`, no `management`, no `unreadableSupply`, no
|
|
2130
|
+
// nullable row group: a failed source fails this read, it does not become a
|
|
2131
|
+
// shape the Client has to interpret.
|
|
2132
|
+
return { schema: 'ptc-capability-session-catalog/v2', sessionId, rows,
|
|
2133
|
+
config: { bindingsEnabled } }
|
|
1348
2134
|
},
|
|
1349
2135
|
async observeAllocation({ sessionId, configuration } = {}, signal) {
|
|
1350
2136
|
signal?.throwIfAborted()
|
|
1351
|
-
|
|
1352
|
-
|
|
2137
|
+
// The request names the session it observes; the live agent behind that id is
|
|
2138
|
+
// the parameter the observation needs. What that agent's tool table declares
|
|
2139
|
+
// is not a property of the target.
|
|
2140
|
+
const agent = (ctx.agents?.list?.() ?? []).find(agent => String(agent.session.id ?? agent.id) === sessionId)
|
|
2141
|
+
if (agent === undefined) throw new TypeError('ptc-plus: select a live session to observe its capability inventory and workspace statistics')
|
|
1353
2142
|
const raw = configuration === undefined ? savedConfiguration(capabilityConfiguration(currentConfig)) : configuration
|
|
1354
2143
|
const validated = capabilityProfileConfiguration(raw, { allowInvalidStatistics: true })
|
|
1355
2144
|
const settings = capabilityConfiguration(validated)
|
|
@@ -1358,35 +2147,43 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1358
2147
|
const coverage = Number.isSafeInteger(settings.statistics?.windowSize) && settings.statistics.windowSize > 0
|
|
1359
2148
|
? await store?.coverage(agent, capabilities, settings.statistics) ?? null : null
|
|
1360
2149
|
signal?.throwIfAborted()
|
|
1361
|
-
if (!(ctx.agents?.list?.() ?? []).includes(agent)
|
|
2150
|
+
if (!(ctx.agents?.list?.() ?? []).includes(agent)) throw new Error('ptc-plus: capability observation target is no longer available')
|
|
1362
2151
|
const plan = createCapabilityPlan({ ...settings, capabilities, coverage, navigationText: NAVIGATION,
|
|
1363
2152
|
configurationRevision: digest(settings) })
|
|
1364
2153
|
return { sessionId, workspace: agent.session.header.cwd, configuration: validated, plan, coverage,
|
|
1365
2154
|
capabilities: capabilities.map(item => ({ id: item.id, revision: item.revision, category: item.category,
|
|
1366
2155
|
contractBytes: Buffer.byteLength(item.contract, 'utf8') })) }
|
|
1367
2156
|
},
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
? { invocation: undefined, invocationConflict: true } : {}) })
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
2157
|
+
/**
|
|
2158
|
+
* The settings page's list of what this Host declares.
|
|
2159
|
+
*
|
|
2160
|
+
* The settings page edits global policy, so its list states supply and nothing
|
|
2161
|
+
* else: no composed plan, no installed value. A caller that names a session gets
|
|
2162
|
+
* that session's own supply — its scope's declaration while the Host still holds
|
|
2163
|
+
* the agent, and the face its recorded plan froze when it does not, both read
|
|
2164
|
+
* through the same entry the panel reads. A caller that names none gets the
|
|
2165
|
+
* Host's own declaration without a workspace directory: that list is the saved
|
|
2166
|
+
* configuration's subject, not any session's face, so it is never substituted for
|
|
2167
|
+
* one. Neither branch asks whether a runtime exists.
|
|
2168
|
+
*
|
|
2169
|
+
* Skill invocation eligibility travels with the entry because it belongs to the
|
|
2170
|
+
* declaration the Host just made; this read sees one workspace, so it never has
|
|
2171
|
+
* two answers to reconcile.
|
|
2172
|
+
*/
|
|
2173
|
+
async describeCatalog({ sessionId } = {}, signal) {
|
|
1388
2174
|
signal?.throwIfAborted()
|
|
1389
|
-
|
|
2175
|
+
const supply = typeof sessionId === 'string' && sessionId
|
|
2176
|
+
? (await sessionCatalogSupply(sessionId, signal)).supply
|
|
2177
|
+
: await hostSupply({}, signal)
|
|
2178
|
+
signal?.throwIfAborted()
|
|
2179
|
+
return supply.capabilities.map(capability => {
|
|
2180
|
+
const display = supply.presentation.get(capability.id)
|
|
2181
|
+
return {
|
|
2182
|
+
...capabilityEntry(capability, supply.presentation),
|
|
2183
|
+
...(display?.invocation === undefined ? {} : { invocation: display.invocation }),
|
|
2184
|
+
...(capability.mcpServer === undefined ? {} : { mcpServer: capability.mcpServer }),
|
|
2185
|
+
}
|
|
2186
|
+
}).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
1390
2187
|
},
|
|
1391
2188
|
async disposeAgent(agent) {
|
|
1392
2189
|
const projection = skillProjections.get(agent)
|