dsh-ptc-plus 0.5.3 → 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 +1 -1
- package/README.md +1 -1
- package/client.cjs +3326 -2881
- package/compiler-core.cjs +127 -127
- package/compiler-core.cjs.map +2 -2
- package/docs/README.md +1 -0
- 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/installation.md +9 -2
- package/docs/panel-concept.md +171 -0
- package/docs/releases/v0.5.4.md +75 -0
- package/docs/runtime-reference.md +14 -0
- package/docs/verification.md +1 -1
- package/index.js +117 -30
- package/internal/binding-adoption.js +103 -11
- package/internal/capability-command-receipt.js +99 -15
- package/internal/capability-environment.js +1108 -348
- package/internal/capability-plan.js +18 -2
- package/internal/capability-policy.js +13 -0
- package/internal/capability-selection.js +330 -0
- package/internal/direct-surface-owner.js +17 -4
- package/internal/message-sources.js +21 -0
- package/internal/program-bindings.js +13 -4
- package/internal/runtime-bridge-owner.js +5 -1
- package/internal/runtime-config.js +10 -0
- package/internal/session-log-view.js +47 -5
- package/internal/settings-compat.js +6 -0
- package/internal/user-bindings-owner.js +20 -3
- package/package.json +45 -31
- package/src/client-authoring-view.js +7 -8
- package/src/client-capabilities-view.js +871 -948
- package/src/client-capability-sort.js +22 -9
- package/src/client-copy.js +116 -294
- package/src/client-settings-view.js +90 -33
- package/src/client-styles.js +67 -19
- package/src/client.js +46 -21
- package/internal/capability-delivery-proof.js +0 -70
|
@@ -1,24 +1,30 @@
|
|
|
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
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'
|
|
9
13
|
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
|
-
import { normalizeCapabilityProof } from './capability-delivery-proof.js'
|
|
13
16
|
import { createCapabilityPlan } from './capability-plan.js'
|
|
14
|
-
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'
|
|
15
21
|
import { deepFreeze } from './record-utils.js'
|
|
16
22
|
import { capabilityProfileConfiguration } from './runtime-config.js'
|
|
17
|
-
import { readRuntimeAvailability } from './runtime-messages.js'
|
|
23
|
+
import { readRuntimeAvailability, readRuntimeMessage } from './runtime-messages.js'
|
|
18
24
|
import { PLAN_CATEGORY_LINES, PLAN_NAVIGATION, PLAN_OVERAGE, PLAN_OVERAGE_HOLE,
|
|
19
25
|
PROMPT_SLOT_IDS, planCategorySlotId, resolvePromptText, resolvePromptTextConfig } from './prompt-text.js'
|
|
20
26
|
|
|
21
|
-
export
|
|
27
|
+
export { CAPABILITY_PLAN_FORM }
|
|
22
28
|
export const CAPABILITY_EVIDENCE_KEY = 'dshPtcPlusCapabilities'
|
|
23
29
|
const EVIDENCE_KEY = CAPABILITY_EVIDENCE_KEY
|
|
24
30
|
const DIRECT_TRANSPORT = 'run_code'
|
|
@@ -138,14 +144,15 @@ function sourcePlan(message, id) {
|
|
|
138
144
|
const snapshot = source.snapshot
|
|
139
145
|
const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3',
|
|
140
146
|
'ptc-capability-environment/v4', 'ptc-capability-environment/v5'].indexOf(snapshot?.schema)
|
|
141
|
-
//
|
|
142
|
-
// shape
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
// settings and therefore a v3 plan, or a closed
|
|
147
|
-
|
|
148
|
-
|
|
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'
|
|
149
156
|
if (source.digest !== digest(snapshot) || generation < 0
|
|
150
157
|
|| !Array.isArray(snapshot.capabilities)
|
|
151
158
|
|| (generation < 4 && snapshot.plan?.schema !== expectedPlanSchema)) {
|
|
@@ -313,16 +320,97 @@ function sectionDependencies(sections) {
|
|
|
313
320
|
.map(section => ({ id: section.name, text: section.text }))
|
|
314
321
|
}
|
|
315
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
|
+
|
|
316
363
|
function requiresCapabilityInventory(settings) {
|
|
317
364
|
settings = effectiveCapabilitySettings(settings)
|
|
318
365
|
return settings.enabled || settings.policy && (settings.statistics?.enabled === true || settings.recoveryEnabled)
|
|
319
366
|
}
|
|
320
367
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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)
|
|
326
414
|
}
|
|
327
415
|
|
|
328
416
|
function savedConfiguration(settings) {
|
|
@@ -347,6 +435,21 @@ function sdkMemberCall(name, callArguments) {
|
|
|
347
435
|
: `tools[${JSON.stringify(name)}](${callArguments})`
|
|
348
436
|
}
|
|
349
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
|
+
|
|
350
453
|
function toolCapability(schema, sections) {
|
|
351
454
|
return { id: `tools.${schema.name}`, category: CAPABILITY_INVENTORY_CATEGORIES.tool,
|
|
352
455
|
revision: digest(schema), schema,
|
|
@@ -433,14 +536,30 @@ function readContractUpdate(message, planDigest, sessionId) {
|
|
|
433
536
|
return snapshot
|
|
434
537
|
}
|
|
435
538
|
|
|
436
|
-
/**
|
|
437
|
-
|
|
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 }) {
|
|
438
547
|
let currentConfig = config
|
|
439
548
|
const sessions = new Map()
|
|
440
549
|
const histories = new WeakMap()
|
|
441
|
-
|
|
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
|
|
442
561
|
async function observeHistory(agent, signal) {
|
|
443
|
-
const query =
|
|
562
|
+
const query = sessionQueryService()
|
|
444
563
|
const log = sessionHistoryForAgent ? await sessionHistoryForAgent(agent, signal) : await readSessionLog(agent.session, query, signal)
|
|
445
564
|
const events = log?.events ?? []
|
|
446
565
|
histories.set(agent.session, events)
|
|
@@ -485,23 +604,35 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
485
604
|
return directories?.get(agent.session) ?? agent.session.header.cwd
|
|
486
605
|
}
|
|
487
606
|
|
|
488
|
-
|
|
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) {
|
|
489
615
|
if (!readSkills(ctx)?.snapshot) return { skills: [], complete: true }
|
|
490
616
|
// Read the original provider layers through a short-lived child scope. The
|
|
491
617
|
// session projection is exact-scope-only, so catalogue reads cannot toggle
|
|
492
618
|
// a global inventory flag and create a window where closed Skills load.
|
|
493
619
|
const child = {}
|
|
494
|
-
const isolated = typeof ctx.plugin === 'function'
|
|
495
|
-
|
|
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
|
|
496
623
|
let observed
|
|
497
624
|
try {
|
|
498
|
-
const combined = [signal,
|
|
499
|
-
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,
|
|
500
627
|
signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0] })
|
|
501
628
|
} finally { await isolated?.dispose() }
|
|
502
629
|
return { ...observed, skills: observed.skills.filter(skill => isModelInvocable(skill) || isUserInvocable(skill)) }
|
|
503
630
|
}
|
|
504
631
|
|
|
632
|
+
function observeSkills(agent, signal) {
|
|
633
|
+
return observeSkillsAt({ scope: agent, cwd: currentDirectory(agent), signal: agent.signal }, signal)
|
|
634
|
+
}
|
|
635
|
+
|
|
505
636
|
function recordedSkills(agent, state) {
|
|
506
637
|
const skills = new Map(state.snapshot.capabilities.filter(item => item.category === 'skill').map(item => [item.id, item]))
|
|
507
638
|
const history = sessionEvents(agent.session)
|
|
@@ -536,9 +667,24 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
536
667
|
return state
|
|
537
668
|
}
|
|
538
669
|
|
|
539
|
-
|
|
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) {
|
|
540
685
|
if (settings) settings = effectiveCapabilitySettings(settings)
|
|
541
|
-
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)))
|
|
542
688
|
const capabilities = schemas.map(schema => toolCapability(schema, sections))
|
|
543
689
|
const declaredServers = new Set([...resourceServers(sections),
|
|
544
690
|
...sections.filter(section => section.name?.startsWith('mcp:')).map(section => section.name.slice(4))])
|
|
@@ -546,16 +692,39 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
546
692
|
const servers = [...declaredServers].filter(server => capability.schema.name.startsWith(`mcp__${server}__`))
|
|
547
693
|
if (servers.length === 1) capability.mcpServer = servers[0]
|
|
548
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.
|
|
549
699
|
presentation?.set(capability.id, { name: capability.schema.name,
|
|
550
700
|
source: servers.length === 1 ? { kind: 'mcp', server: servers[0] } : servers.length > 1
|
|
551
|
-
? { kind: '
|
|
701
|
+
? { kind: 'mcp', servers } : { kind: 'host' } })
|
|
552
702
|
}
|
|
553
703
|
if (skillObservation) skillObservation.complete = true
|
|
554
|
-
const hasOperation = name => (!frozenIds || frozenIds.has(`tools.${name}`)) && capabilities.some(item => item.id === `tools.${name}`
|
|
555
|
-
&& (!settings?.enabled || !settings.policy || capabilityRule(settings.policy, item).availability !== 'closed'))
|
|
556
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))
|
|
557
726
|
if (readSkills(ctx)?.snapshot !== undefined) {
|
|
558
|
-
const observed = await observeSkills(agent, signal)
|
|
727
|
+
const observed = access?.skills ?? await observeSkills(agent, signal)
|
|
559
728
|
if (skillObservation) {
|
|
560
729
|
skillObservation.complete = observed.complete
|
|
561
730
|
skillObservation.partialNames = observed.complete ? [] : observed.skills.map(skill => skill.name)
|
|
@@ -564,29 +733,30 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
564
733
|
// A partial catalog cannot prove a withdrawal or a new Skill. Preserve only
|
|
565
734
|
// the last complete view; native dispatch still uses the live Host scope.
|
|
566
735
|
if (!observed.complete) capabilities.push(...skillObservation.retained.map(item =>
|
|
567
|
-
retainedSkillCapability(item,
|
|
736
|
+
retainedSkillCapability(item, operationPath('skill'))))
|
|
568
737
|
for (const skill of observed.complete ? observed.skills : []) {
|
|
569
|
-
capabilities.push(skillCapability(skill,
|
|
738
|
+
capabilities.push(skillCapability(skill, operationPath('skill')))
|
|
570
739
|
presentation?.set(`skills.${skill.name}`, { name: skill.name,
|
|
571
740
|
source: { kind: 'skill', bucket: skill.source, provider: skill.provider },
|
|
572
741
|
invocation: { modelInvocable: isModelInvocable(skill), userInvocable: isUserInvocable(skill) } })
|
|
573
742
|
}
|
|
574
743
|
}
|
|
575
744
|
const servers = resourceServers(sections)
|
|
576
|
-
const resourceOperations =
|
|
577
|
-
|
|
745
|
+
const resourceOperations = RESOURCE_OPERATIONS.filter(name => operationPath(name))
|
|
746
|
+
const resourceNotice = resourceOperationNotice(resourceOperations, policyPaths)
|
|
578
747
|
for (const server of servers) capabilities.push({ id: `mcp.${server}`, category: CAPABILITY_INVENTORY_CATEGORIES.resource, mcpServer: server,
|
|
579
748
|
revision: digest({ server, instructions: sections.find(section => section.name === `mcp:${server}`)?.text ?? '' }),
|
|
580
|
-
dependencies: sectionDependencies(sections.filter(section => section.name === `mcp:${server}`)),
|
|
581
|
-
|
|
582
|
-
|
|
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}`,
|
|
583
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.`,
|
|
584
754
|
selector: { server } })
|
|
585
755
|
for (const server of servers) presentation?.set(`mcp.${server}`, { name: server, source: { kind: 'mcp', server } })
|
|
586
756
|
return capabilities.filter(item => {
|
|
587
757
|
if (item.category !== 'tool') return true
|
|
588
758
|
const category = item.schema.name === 'skill' ? 'skill'
|
|
589
|
-
:
|
|
759
|
+
: RESOURCE_OPERATIONS.includes(item.schema.name) ? 'resource' : undefined
|
|
590
760
|
return category === undefined || capabilities.some(member => member.category === category
|
|
591
761
|
&& (category !== 'skill' || member.invocation.modelInvocable)
|
|
592
762
|
&& (!settings?.enabled || (settings.policy ? capabilityRule(settings.policy, member).availability !== 'closed' : settings.manual.overrides[member.id] !== 'closed'))
|
|
@@ -594,8 +764,105 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
594
764
|
})
|
|
595
765
|
}
|
|
596
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
|
+
}
|
|
597
865
|
function stateFor(agent) { return sessions.get(String(agent?.session?.id ?? agent?.id)) }
|
|
598
|
-
const canRefresh = agent => modelSchemasForAgent(agent).some(schema => schema.name === 'run_code')
|
|
599
866
|
function planCommitted(agent, message) {
|
|
600
867
|
return message !== undefined && (sessionEvents(agent.session).some(event =>
|
|
601
868
|
event.type === 'user/message' && event.data?.id === message.id)
|
|
@@ -609,6 +876,43 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
609
876
|
if (!sessionEvents(agent.session).some(event => event.type === 'system/message')) return
|
|
610
877
|
agent.session.append('user/message', message, { surfaceOp: 'append' })
|
|
611
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
|
+
}
|
|
612
916
|
function selection(state, id) { return state?.snapshot.plan.entries.find(entry => entry.id === id)?.selection }
|
|
613
917
|
function liveCapability(state, id) {
|
|
614
918
|
return state?.liveCapabilities?.get(id) ?? state?.snapshot.capabilities.find(item => item.id === id)
|
|
@@ -626,6 +930,9 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
626
930
|
* member's identity from the model-visible surface.
|
|
627
931
|
*/
|
|
628
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'
|
|
629
936
|
const planned = selection(state, id)
|
|
630
937
|
if (planned !== undefined || !state?.snapshot.plan.enabled) return planned
|
|
631
938
|
const live = state.liveCapabilities?.get(id)
|
|
@@ -636,17 +943,23 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
636
943
|
return overrides[id] === 'closed'
|
|
637
944
|
|| (live.mcpServer !== undefined && overrides[`mcpServers.${live.mcpServer}`] === 'closed') ? 'closed' : 'host'
|
|
638
945
|
}
|
|
639
|
-
function supplied(state, id) {
|
|
946
|
+
function supplied(state, id, decisions = decisionsOf(state)) {
|
|
947
|
+
if (recordCloses(decisions, id, liveCapability(state, id))) return false
|
|
640
948
|
if (state?.liveCapabilities && (!state.liveCapabilities.has(id) || state.liveCapabilities.get(id).effectiveAvailability === 'closed')) return false
|
|
641
949
|
if (!state?.snapshot.plan.enabled) return Boolean(state?.liveCapabilities) || !state?.snapshot.settings.policy || selection(state, id) !== 'closed'
|
|
642
950
|
if (!['full', 'on-demand', 'host'].includes(declaredSelection(state, id))) return false
|
|
643
951
|
const capability = liveCapability(state, id)
|
|
644
|
-
|
|
645
|
-
|
|
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))
|
|
646
959
|
}
|
|
647
960
|
function sharedMembers(state, name) {
|
|
648
961
|
const category = name === 'skill' ? 'skill'
|
|
649
|
-
:
|
|
962
|
+
: RESOURCE_OPERATIONS.includes(name) ? 'resource' : undefined
|
|
650
963
|
return category === undefined ? undefined : [...declaredCapabilities(state).values()]
|
|
651
964
|
.filter(item => item.category === category && supplied(state, item.id)
|
|
652
965
|
&& (category !== 'skill' || (state.liveCapabilities?.get(item.id) ?? item).invocation?.modelInvocable !== false))
|
|
@@ -655,6 +968,31 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
655
968
|
function memberSupplied(state, name) {
|
|
656
969
|
return TRANSPORTS.has(name) || (supplied(state, `tools.${name}`) && sharedMembers(state, name)?.length !== 0)
|
|
657
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
|
+
}
|
|
658
996
|
function statisticsSnapshot(state) {
|
|
659
997
|
if (!state.liveCapabilities) return state.snapshot
|
|
660
998
|
const eligible = item => state.skillInventoryComplete !== false || item.category !== 'skill'
|
|
@@ -796,52 +1134,106 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
796
1134
|
return previewPolicies.get(cap)
|
|
797
1135
|
}
|
|
798
1136
|
|
|
799
|
-
|
|
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) {
|
|
800
1160
|
signal?.throwIfAborted()
|
|
801
1161
|
if (agent?.session === undefined) {
|
|
802
1162
|
if (!effectiveCapabilitySettings(capabilityConfiguration(currentConfig)).enabled) return undefined
|
|
803
1163
|
throw new TypeError('ptc-plus: capability management requires an agent Session')
|
|
804
1164
|
}
|
|
805
|
-
await observeHistory(agent, signal)
|
|
806
|
-
await reconcilePendingChoice(agent, signal)
|
|
1165
|
+
const history = await observeHistory(agent, signal)
|
|
807
1166
|
const id = String(agent.session.id ?? agent.id)
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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)
|
|
812
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
|
|
813
1180
|
const pending = operations.get(agent)
|
|
814
1181
|
if (pending !== undefined && !force) return pending
|
|
815
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
|
|
816
1187
|
if (!force) {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
829
1210
|
}
|
|
830
|
-
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() }
|
|
831
|
-
await qualifyCapabilities(agent, restored, signal)
|
|
832
|
-
signal?.throwIfAborted()
|
|
833
|
-
restoreEvidence(agent, restored)
|
|
834
|
-
return acceptState(agent, restored, false, signal)
|
|
835
1211
|
}
|
|
836
1212
|
}
|
|
837
1213
|
const historical = !force && !sessionEvents(agent.session).some(event => event.type === 'user/message'
|
|
838
1214
|
&& readRuntimeAvailability(event.data) === 'disabled') && sessionEvents(agent.session).some(event =>
|
|
839
1215
|
event.type === 'assistant/message' || event.type === 'tool/result')
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
+
}
|
|
843
1228
|
const settings = sessionCapabilitySettings(sourceSettings, sessionOverrides)
|
|
844
|
-
|
|
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
|
+
: []
|
|
845
1237
|
const effective = effectiveCapabilitySettings(settings)
|
|
846
1238
|
const coverage = (effective.policy ? effective.enabled && effective.policy.allocation.statistics : effective.statistics?.enabled === true) ? await store?.coverage(agent, capabilities, settings.statistics) : undefined
|
|
847
1239
|
const planChannel = planChannelText(currentConfig)
|
|
@@ -850,7 +1242,8 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
850
1242
|
capabilities, coverage, navigationText: planChannel.navigationSentence,
|
|
851
1243
|
categories: planChannel.categoryLines,
|
|
852
1244
|
directoryBudgetBytes })
|
|
853
|
-
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'
|
|
854
1247
|
// An edited plan channel needs the v5 record, and that generation requires the
|
|
855
1248
|
// saved inputs and session choices it was composed from.
|
|
856
1249
|
const recordsText = !planChannel.isDefault
|
|
@@ -861,43 +1254,387 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
861
1254
|
...(recordsText ? planPromptText(planChannel, directoryBudgetBytes) : {}),
|
|
862
1255
|
navigationText: planChannel.navigationSentence, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
|
|
863
1256
|
const initialized = sessionEvents(agent.session).some(event => event.type === 'system/message')
|
|
864
|
-
|
|
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)
|
|
865
1261
|
agent.signal?.throwIfAborted?.()
|
|
866
1262
|
signal?.throwIfAborted()
|
|
867
1263
|
// Initialized sessions commit a refresh and its notification atomically.
|
|
868
1264
|
// First-step proposals are accepted through the Host's pre-step messages.
|
|
869
1265
|
const state = { snapshot, message: settings.contextEnabled === undefined && !effective.enabled && !force && !(effective.policy && (effective.statistics?.enabled || effective.recoveryEnabled || effective.previewEnabled)) ? undefined : message,
|
|
870
|
-
hostSections:
|
|
1266
|
+
hostSections, ...(hostTools === undefined ? {} : { hostTools }), expanded: new Set(), used: new Map(),
|
|
871
1267
|
...(requiresCapabilityInventory(settings) ? { liveCapabilities: new Map(capabilities.map(item => [item.id, item])) } : {}) }
|
|
872
1268
|
restoreEvidence(agent, state)
|
|
873
|
-
if (choice?.commandId && !initialized) {
|
|
874
|
-
state.pendingCommand = choice.commandId
|
|
875
|
-
state.pendingPrevious = stateFor(agent)
|
|
876
|
-
}
|
|
877
1269
|
return acceptState(agent, state, force, signal)
|
|
878
1270
|
})()
|
|
879
1271
|
operations.set(agent, task)
|
|
880
1272
|
try { return await task } finally { if (operations.get(agent) === task) operations.delete(agent) }
|
|
881
1273
|
}
|
|
882
1274
|
|
|
883
|
-
|
|
1275
|
+
function visibleSchemas(agent, schemas) {
|
|
884
1276
|
const state = stateFor(agent)
|
|
885
|
-
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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)
|
|
893
1311
|
}
|
|
894
|
-
|
|
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 }
|
|
895
1320
|
}
|
|
896
1321
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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 }
|
|
901
1638
|
}
|
|
902
1639
|
|
|
903
1640
|
return Object.freeze({
|
|
@@ -906,184 +1643,158 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
906
1643
|
stateFor,
|
|
907
1644
|
visibleSchemas,
|
|
908
1645
|
validateSupply,
|
|
909
|
-
|
|
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) {
|
|
910
1654
|
signal?.throwIfAborted()
|
|
1655
|
+
if (agent === undefined) throw new TypeError('ptc-plus: a capability refresh needs an agent')
|
|
911
1656
|
if (typeof agent.runMaintenance !== 'function') throw new Error('ptc-plus: capability refresh requires the public agent maintenance boundary')
|
|
912
1657
|
return agent.runMaintenance(async maintenanceSignal => {
|
|
913
|
-
|
|
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.
|
|
914
1660
|
const assembly = await ctx.systemPrompt.assemble({ agent, scope: agent, signal: maintenanceSignal })
|
|
915
1661
|
signal?.throwIfAborted()
|
|
916
1662
|
maintenanceSignal.throwIfAborted()
|
|
917
1663
|
const preparationSignal = AbortSignal.any([signal, maintenanceSignal, agent.signal].filter(Boolean))
|
|
918
|
-
|
|
919
|
-
return prepare(agent, stateFor(agent)?.hostSections ?? assembly.sections, true, preparationSignal,
|
|
920
|
-
commandId === undefined ? undefined : {
|
|
921
|
-
sourceSettings: capabilityConfiguration(currentConfig), sessionOverrides: previous?.sessionOverrides ?? {}, commandId,
|
|
922
|
-
})
|
|
1664
|
+
return prepare(agent, sections ?? stateFor(agent)?.hostSections ?? assembly.sections, true, preparationSignal)
|
|
923
1665
|
})
|
|
924
1666
|
},
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const recorded = recordedPlan(history, String(agent.session.id ?? agent.id))
|
|
935
|
-
const snapshot = stateFor(agent)?.snapshot ?? (recorded && sourcePlan(recorded, String(agent.session.id ?? agent.id)))
|
|
936
|
-
const state = snapshot ? { snapshot } : undefined
|
|
937
|
-
const sourceSettings = snapshot?.sourceSettings ?? snapshot?.settings
|
|
938
|
-
const sourceOverrides = sourceSettings?.manual?.overrides
|
|
939
|
-
const sessionSourceSelection = id => sourceOverrides?.[id] ?? (sourceOverrides ? 'automatic' : 'unknown')
|
|
940
|
-
// A paused or unmanaged session has no frozen allocation to report: every
|
|
941
|
-
// row then states the one truth that applies, the Host default. Only a
|
|
942
|
-
// managed session may report a pending refresh boundary.
|
|
943
|
-
const planManaged = state !== undefined && effectiveCapabilitySettings(state.snapshot.settings).enabled === true
|
|
944
|
-
const management = state === undefined ? 'unknown'
|
|
945
|
-
: state.snapshot.settings.contextEnabled === false ? 'paused'
|
|
946
|
-
: planManaged ? 'managed' : 'off'
|
|
947
|
-
const dimensions = capability => {
|
|
948
|
-
const saved = capabilityConfiguration(currentConfig)
|
|
949
|
-
if (!saved.policy && !snapshot?.settings.policy) return { managementEnabled: snapshot && effectiveCapabilitySettings(snapshot.settings).enabled }
|
|
950
|
-
const source = sourceSettings?.policy ? capabilityRule(sourceSettings.policy, capability) : undefined
|
|
951
|
-
const temporary = snapshot?.sessionOverrides?.[capability.id]
|
|
952
|
-
const rule = rules => capability.id.startsWith('mcpServers.') ? rules.mcpServers[capability.id.slice(11)] ?? {} : rules.items[capability.id] ?? {}
|
|
953
|
-
return { managementEnabled: snapshot && effectiveCapabilitySettings(snapshot.settings).enabled, ...(saved.policy ? { savedRule: rule(saved.policy) } : {}),
|
|
954
|
-
...(sourceSettings?.policy ? { sourceRule: rule(sourceSettings.policy), sourceDimensions: source } : {}),
|
|
955
|
-
temporaryRule: temporaryCapabilityRule(temporary),
|
|
956
|
-
...(snapshot?.settings.policy ? { effectiveDimensions: snapshot.plan.entries.find(entry => entry.id === capability.id)
|
|
957
|
-
?? (capability.category === 'server' ? effectiveCapabilitySettings(snapshot.settings).enabled
|
|
958
|
-
? capabilityRule(snapshot.settings.policy, capability) : { availability: 'enabled', disclosure: 'host', availabilitySource: 'host', disclosureSource: 'host' } : undefined) } : {}) }
|
|
959
|
-
}
|
|
960
|
-
const entries = capabilities.map(capability => ({ id: capability.id, category: capability.category,
|
|
961
|
-
description: capability.description, ...presentation.get(capability.id), ...dimensions(capability),
|
|
962
|
-
...(capability.mcpServer === undefined ? {} : { mcpServer: capability.mcpServer }),
|
|
963
|
-
saved: capabilityConfiguration(currentConfig).manual.overrides[capability.id] ?? 'automatic',
|
|
964
|
-
sessionSourceSelection: sessionSourceSelection(capability.id),
|
|
965
|
-
temporary: typeof state?.snapshot.sessionOverrides?.[capability.id] === 'object' ? 'inherit' : state?.snapshot.sessionOverrides?.[capability.id] ?? 'inherit',
|
|
966
|
-
effective: state === undefined ? 'pending'
|
|
967
|
-
: planManaged ? state.snapshot.plan.entries.find(entry => entry.id === capability.id)?.selection ?? 'pending'
|
|
968
|
-
: 'host' }))
|
|
969
|
-
for (const server of new Set(capabilities.map(item => item.mcpServer).filter(Boolean))) {
|
|
970
|
-
const id = `mcpServers.${server}`
|
|
971
|
-
const enclosingClosure = state ? effectiveCapabilitySettings(state.snapshot.settings).enabled && (state.snapshot.settings.policy
|
|
972
|
-
? state.snapshot.settings.policy.mcpServers[server]?.availability === 'closed'
|
|
973
|
-
: state.snapshot.settings.manual.overrides[id] === 'closed') : undefined
|
|
974
|
-
const effective = state === undefined ? 'pending'
|
|
975
|
-
: !planManaged ? 'host'
|
|
976
|
-
: state.snapshot.settings.policy
|
|
977
|
-
? capabilityRule(state.snapshot.settings.policy, { id, category: 'server', mcpServer: server }).availability
|
|
978
|
-
: enclosingClosure ? 'closed' : 'enabled'
|
|
979
|
-
entries.push({ id, category: 'server', name: server, description: `MCP server ${server}`,
|
|
980
|
-
source: { kind: 'mcp', server }, ...dimensions({ id, category: 'server', mcpServer: server }), saved: capabilityConfiguration(currentConfig).manual.overrides[id] ?? 'automatic',
|
|
981
|
-
sessionSourceSelection: sessionSourceSelection(id),
|
|
982
|
-
temporary: typeof state?.snapshot.sessionOverrides?.[id] === 'object' ? 'inherit' : state?.snapshot.sessionOverrides?.[id] ?? 'inherit',
|
|
983
|
-
enclosingClosure, effective })
|
|
984
|
-
}
|
|
985
|
-
return { management, entries }
|
|
986
|
-
},
|
|
987
|
-
async catalogFor(agent, signal) {
|
|
988
|
-
return (await this.catalogObservation(agent, signal)).entries
|
|
989
|
-
},
|
|
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
|
+
*/
|
|
990
1676
|
async change(agent, intent, signal, globalSettings) {
|
|
991
|
-
if (!canRefresh(agent)) throw new Error('ptc-plus: select an active PTC session')
|
|
992
1677
|
const dimension = intent.dimension
|
|
993
|
-
const
|
|
1678
|
+
const selections = dimension === 'availability' ? ['enabled', 'closed', 'automatic', 'inherit']
|
|
994
1679
|
: dimension === 'disclosure' ? [...CAPABILITY_DISCLOSURES, 'automatic', 'inherit']
|
|
995
1680
|
: dimension === undefined ? ['full', 'on-demand', 'closed', 'automatic', 'inherit'] : []
|
|
996
|
-
if (!['session', 'global'].includes(intent.scope) || !
|
|
1681
|
+
if (!['session', 'global'].includes(intent.scope) || !selections.includes(intent.selection)
|
|
997
1682
|
|| intent.scope === 'global' && intent.selection === 'inherit') throw new TypeError('invalid capability change')
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const
|
|
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
|
-
|
|
1052
|
-
globalSaved = true
|
|
1053
|
-
}
|
|
1054
|
-
try {
|
|
1055
|
-
if (globalSaved) {
|
|
1056
|
-
const saved = globalSettings.read()
|
|
1057
|
-
if (!saved?.value || !Number.isSafeInteger(saved.revision)) throw new Error('ptc-plus: saved settings could not be confirmed for this session')
|
|
1058
|
-
sourceSettings = capabilityConfiguration({ ...currentConfig, ...saved.value })
|
|
1059
|
-
if (dimension) {
|
|
1060
|
-
const retained = temporaryCapabilityRule(sessionOverrides[intent.id]); delete retained[dimension]
|
|
1061
|
-
if (Object.keys(retained).length) sessionOverrides[intent.id] = retained
|
|
1062
|
-
else delete sessionOverrides[intent.id]
|
|
1063
|
-
} else delete sessionOverrides[intent.id]
|
|
1064
|
-
}
|
|
1065
|
-
combined.throwIfAborted()
|
|
1066
|
-
if (!effectiveCapabilitySettings(sourceSettings).enabled) throw new Error('ptc-plus: enable capability management before changing a session choice')
|
|
1067
|
-
await prepare(agent, assembly.sections, true, combined, { sourceSettings, sessionOverrides, commandId: intent.commandId })
|
|
1068
|
-
return { globalSaved, sessionApplied: true, publication: planCommitted(agent, stateFor(agent).message) ? 'accepted' : 'pending' }
|
|
1069
|
-
} catch (error) {
|
|
1070
|
-
if (globalSaved) return { globalSaved: true, sessionApplied: false, error: String(error?.message ?? error) }
|
|
1071
|
-
throw error
|
|
1072
|
-
}
|
|
1073
|
-
})
|
|
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 }
|
|
1074
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
|
+
*/
|
|
1075
1745
|
pendingChoiceMessage(agent) {
|
|
1076
1746
|
const state = stateFor(agent)
|
|
1077
|
-
return
|
|
1747
|
+
return state?.message !== undefined && !planCommitted(agent, state.message) ? state.message : undefined
|
|
1078
1748
|
},
|
|
1079
|
-
|
|
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) {
|
|
1080
1764
|
const state = stateFor(agent)
|
|
1081
|
-
|
|
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)
|
|
1082
1789
|
const inventory = [...declaredCapabilities(state).values()]
|
|
1083
|
-
|
|
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))
|
|
1084
1795
|
const retainedSections = new Set(retained.flatMap(item => item.dependencies ?? []).map(item => item.id))
|
|
1085
1796
|
const ownedSections = new Set(inventory.flatMap(item => item.dependencies ?? []).map(item => item.id))
|
|
1086
|
-
return
|
|
1797
|
+
return declared.flatMap(section => {
|
|
1087
1798
|
if (section.name === 'mcp-resource-servers') {
|
|
1088
1799
|
const servers = retained.filter(item => item.category === 'resource').map(item => item.selector.server)
|
|
1089
1800
|
return servers.length ? [{ ...section, text: `Use the following MCP resource servers as the server argument: ${JSON.stringify(servers)}.` }] : []
|
|
@@ -1094,32 +1805,88 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1094
1805
|
return [section]
|
|
1095
1806
|
})
|
|
1096
1807
|
},
|
|
1097
|
-
|
|
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) {
|
|
1098
1822
|
const state = stateFor(agent)
|
|
1099
|
-
|
|
1823
|
+
const decisions = decisionsFor(agent)
|
|
1100
1824
|
const candidates = toolSchemasForAgent(agent)
|
|
1101
|
-
|
|
1102
|
-
|
|
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'
|
|
1103
1838
|
&& memberSupplied(state, schema.name))
|
|
1104
|
-
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
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)
|
|
1110
1844
|
},
|
|
1111
1845
|
projectMembers(agent, members) {
|
|
1112
1846
|
const state = stateFor(agent)
|
|
1113
|
-
|
|
1114
|
-
|
|
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))
|
|
1115
1851
|
},
|
|
1116
|
-
|
|
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) {
|
|
1117
1876
|
const state = stateFor(agent)
|
|
1118
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))
|
|
1119
1881
|
const capabilities = declaredCapabilities(state)
|
|
1120
1882
|
return ['skill', 'resource'].map(category => ({ namespace: category === 'skill' ? 'skills' : 'mcp',
|
|
1121
1883
|
members: [...capabilities.values()].filter(item => item.category === category && supplied(state, item.id))
|
|
1122
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')
|
|
1123
1890
|
.map(item => ({ name: item.id.slice(item.id.indexOf('.') + 1), description: item.description,
|
|
1124
1891
|
contract: item.contract, dependencies: item.dependencies ?? [], revision: item.revision, parameters: {}, returns: {},
|
|
1125
1892
|
...(item.invocation === undefined ? {} : { invocation: item.invocation }),
|
|
@@ -1153,7 +1920,6 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1153
1920
|
const decision = await next()
|
|
1154
1921
|
if (decision.kind !== 'enter' || payload.signal?.aborted) return decision
|
|
1155
1922
|
await observeHistory(payload.agent, payload.signal)
|
|
1156
|
-
await reconcilePendingChoice(payload.agent, payload.signal, true)
|
|
1157
1923
|
const state = stateFor(payload.agent)
|
|
1158
1924
|
if (decision.kind !== 'enter' || !state || payload.signal?.aborted) return decision
|
|
1159
1925
|
await statisticsOperation(() => store?.available(payload.agent, statisticsSnapshot(state)))
|
|
@@ -1241,7 +2007,11 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1241
2007
|
}
|
|
1242
2008
|
// Before task history exists, the initial plan precedes admitted task messages.
|
|
1243
2009
|
// Refresh and recovery append after committed history; they never reorder it.
|
|
1244
|
-
|
|
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))
|
|
1245
2015
|
return { ...decision, messages: initial ? [...additions, ...messages] : [...messages, ...additions] }
|
|
1246
2016
|
},
|
|
1247
2017
|
admitted(exec) {
|
|
@@ -1307,86 +2077,68 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1307
2077
|
member === agent ? status : member.status))
|
|
1308
2078
|
}
|
|
1309
2079
|
},
|
|
1310
|
-
/**
|
|
1311
|
-
* The saved, delivered and usable facts of one capability. A caller that
|
|
1312
|
-
* only has "the save succeeded" must not report more than this returns.
|
|
1313
|
-
*/
|
|
1314
|
-
describeCapabilityProof(agent, id) {
|
|
1315
|
-
if (typeof id !== 'string' || id.length === 0) throw new TypeError('ptc-plus: capability proof requires a capability id')
|
|
1316
|
-
const state = stateFor(agent)
|
|
1317
|
-
const sourceSettings = state?.snapshot.sourceSettings ?? state?.snapshot.settings
|
|
1318
|
-
?? capabilityConfiguration(currentConfig)
|
|
1319
|
-
// A global choice is recorded either as a legacy override or as a rule in the
|
|
1320
|
-
// policy this owner writes for `/capabilities set global`, so both spellings
|
|
1321
|
-
// prove the same save.
|
|
1322
|
-
const capability = state?.snapshot.capabilities.find(item => item.id === id)
|
|
1323
|
-
const recorded = capability === undefined || sourceSettings.policy === undefined
|
|
1324
|
-
? undefined : capabilityRule(sourceSettings.policy, capability)
|
|
1325
|
-
const saved = Object.hasOwn(sourceSettings.manual?.overrides ?? {}, id)
|
|
1326
|
-
|| recorded?.availabilitySource === `item:${id}`
|
|
1327
|
-
|| recorded?.disclosureSource === `item:${id}`
|
|
1328
|
-
// Delivery is a statement about the model-visible catalog. A plan that holds an
|
|
1329
|
-
// entry whose choice is closed describes an entry the catalog does not list, so
|
|
1330
|
-
// holding it proves nothing was delivered.
|
|
1331
|
-
const entry = state?.snapshot.plan.entries.find(item => item.id === id)
|
|
1332
|
-
const listed = entry !== undefined && entry.selection !== 'closed'
|
|
1333
|
-
const delivery = !listed ? 'absent'
|
|
1334
|
-
: state.message === undefined || planCommitted(agent, state.message) ? 'delivered' : 'pending'
|
|
1335
|
-
return normalizeCapabilityProof({
|
|
1336
|
-
schema: 'ptc-capability-proof/v1',
|
|
1337
|
-
id,
|
|
1338
|
-
saved,
|
|
1339
|
-
delivery,
|
|
1340
|
-
// Only a completed execution in this session proves usability; the
|
|
1341
|
-
// worker's installed schemas and the published catalog prove nothing
|
|
1342
|
-
// about what a cell actually obtained.
|
|
1343
|
-
usability: state?.used?.has(id) === true ? 'proven' : 'unproven',
|
|
1344
|
-
})
|
|
1345
|
-
},
|
|
1346
2080
|
describe(agent) {
|
|
1347
2081
|
const state = stateFor(agent)
|
|
1348
|
-
|
|
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 }
|
|
1349
2088
|
: { configured: capabilityConfiguration(currentConfig), effective: state.snapshot.plan,
|
|
1350
2089
|
publication: state.message === undefined ? 'not-required' : planCommitted(agent, state.message) ? 'accepted' : 'pending',
|
|
1351
2090
|
sourceSettings: state.snapshot.sourceSettings ?? state.snapshot.settings,
|
|
1352
|
-
sessionOverrides
|
|
2091
|
+
sessionOverrides,
|
|
1353
2092
|
pendingRefresh: digest(capabilityConfiguration(currentConfig)) !== digest(state.snapshot.sourceSettings ?? state.snapshot.settings) }
|
|
1354
2093
|
},
|
|
1355
2094
|
describeSessions() {
|
|
1356
2095
|
return [...sessions].map(([sessionId, state]) => ({ sessionId, effective: state.snapshot.plan,
|
|
1357
2096
|
publication: state.message === undefined ? 'not-required' : planCommitted({ session: state.session }, state.message) ? 'accepted' : 'pending',
|
|
1358
2097
|
configuration: savedConfiguration(state.snapshot.sourceSettings ?? state.snapshot.settings),
|
|
1359
|
-
sessionOverrides: state.
|
|
2098
|
+
sessionOverrides: sessionOverridesOf(capabilitySelectionOf(sessionEvents(state.session), sessionId)),
|
|
1360
2099
|
pendingRefresh: digest(capabilityConfiguration(currentConfig)) !== digest(state.snapshot.sourceSettings ?? state.snapshot.settings) }))
|
|
1361
2100
|
},
|
|
1362
|
-
|
|
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
|
+
*/
|
|
1363
2109
|
describeTargets() {
|
|
1364
|
-
return (ctx.agents?.list?.() ?? []).
|
|
1365
|
-
.map(agent => ({ sessionId: String(agent.session.id ?? agent.id) }))
|
|
2110
|
+
return (ctx.agents?.list?.() ?? []).map(agent => ({ sessionId: String(agent.session.id ?? agent.id) }))
|
|
1366
2111
|
},
|
|
1367
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))
|
|
1368
2128
|
signal?.throwIfAborted()
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
signal?.throwIfAborted()
|
|
1375
|
-
if (!(ctx.agents?.list?.() ?? []).includes(agent) || !canRefresh(agent)
|
|
1376
|
-
|| String(agent.session.id ?? agent.id) !== sessionId) {
|
|
1377
|
-
throw new Error('ptc-plus: capability observation target is no longer available')
|
|
1378
|
-
}
|
|
1379
|
-
const savedSettings = capabilityConfiguration(currentConfig)
|
|
1380
|
-
// The saved group and management switches gate session writes in change();
|
|
1381
|
-
// report them so the Client can block staged choices with the reason.
|
|
1382
|
-
return { schema: 'ptc-capability-session-catalog/v1', management, entries,
|
|
1383
|
-
savedSettings: { enabled: effectiveCapabilitySettings(savedSettings).enabled === true,
|
|
1384
|
-
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 } }
|
|
1385
2134
|
},
|
|
1386
2135
|
async observeAllocation({ sessionId, configuration } = {}, signal) {
|
|
1387
2136
|
signal?.throwIfAborted()
|
|
1388
|
-
|
|
1389
|
-
|
|
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')
|
|
1390
2142
|
const raw = configuration === undefined ? savedConfiguration(capabilityConfiguration(currentConfig)) : configuration
|
|
1391
2143
|
const validated = capabilityProfileConfiguration(raw, { allowInvalidStatistics: true })
|
|
1392
2144
|
const settings = capabilityConfiguration(validated)
|
|
@@ -1395,35 +2147,43 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
|
|
|
1395
2147
|
const coverage = Number.isSafeInteger(settings.statistics?.windowSize) && settings.statistics.windowSize > 0
|
|
1396
2148
|
? await store?.coverage(agent, capabilities, settings.statistics) ?? null : null
|
|
1397
2149
|
signal?.throwIfAborted()
|
|
1398
|
-
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')
|
|
1399
2151
|
const plan = createCapabilityPlan({ ...settings, capabilities, coverage, navigationText: NAVIGATION,
|
|
1400
2152
|
configurationRevision: digest(settings) })
|
|
1401
2153
|
return { sessionId, workspace: agent.session.header.cwd, configuration: validated, plan, coverage,
|
|
1402
2154
|
capabilities: capabilities.map(item => ({ id: item.id, revision: item.revision, category: item.category,
|
|
1403
2155
|
contractBytes: Buffer.byteLength(item.contract, 'utf8') })) }
|
|
1404
2156
|
},
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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) {
|
|
2174
|
+
signal?.throwIfAborted()
|
|
2175
|
+
const supply = typeof sessionId === 'string' && sessionId
|
|
2176
|
+
? (await sessionCatalogSupply(sessionId, signal)).supply
|
|
2177
|
+
: await hostSupply({}, signal)
|
|
1425
2178
|
signal?.throwIfAborted()
|
|
1426
|
-
return
|
|
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)
|
|
1427
2187
|
},
|
|
1428
2188
|
async disposeAgent(agent) {
|
|
1429
2189
|
const projection = skillProjections.get(agent)
|