@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.2
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/LICENSE +201 -0
- package/NOTICE +1 -0
- package/README.md +124 -2
- package/THIRD_PARTY_NOTICES.md +66 -0
- package/ZCode-LICENSE +202 -0
- package/dist/index.mjs +4871 -0
- package/dist/native-command-plan.LICENSE.txt +221 -0
- package/dist/native-command-plan.json +16 -0
- package/dist/native-command-plan.mjs +8 -0
- package/dist/rpc-entry.mjs +5682 -0
- package/package.json +169 -4
- package/src/native/a2a-receipt-source.mjs +19 -0
- package/src/native/a2a-tools.mjs +517 -0
- package/src/native/activity-status.mjs +30 -0
- package/src/native/artifact-codec.mjs +481 -0
- package/src/native/artifact-store.mjs +1162 -0
- package/src/native/assistant-stream.mjs +76 -0
- package/src/native/background-command-port.mjs +95 -0
- package/src/native/browser-action-observation.mjs +43 -0
- package/src/native/browser-availability.mjs +22 -0
- package/src/native/browser-canvas-metrics.mjs +71 -0
- package/src/native/browser-dom.mjs +193 -0
- package/src/native/browser-playwright.mjs +1249 -0
- package/src/native/browser-vision.mjs +369 -0
- package/src/native/browser-workspace-policy.mjs +22 -0
- package/src/native/browser-workspace-protocol.mjs +95 -0
- package/src/native/browser-workspace-server.mjs +164 -0
- package/src/native/browser-workspace.mjs +355 -0
- package/src/native/captured-source.mjs +3 -0
- package/src/native/child-budget-allocation.mjs +87 -0
- package/src/native/child-capabilities.mjs +35 -0
- package/src/native/child-command-resource.mjs +20 -0
- package/src/native/child-history.mjs +188 -0
- package/src/native/child-merge.mjs +98 -0
- package/src/native/child-model-surface.mjs +19 -0
- package/src/native/child-observation.mjs +100 -0
- package/src/native/child-owner.mjs +620 -0
- package/src/native/child-publication.mjs +76 -0
- package/src/native/child-readers.mjs +366 -0
- package/src/native/child-tools.mjs +46 -0
- package/src/native/child-workspace-binding.mjs +29 -0
- package/src/native/child-workspace-tools.mjs +1 -0
- package/src/native/child-workspaces.mjs +655 -0
- package/src/native/children.mjs +365 -0
- package/src/native/command-plan-worker.mjs +14 -0
- package/src/native/command-plan.mjs +31 -0
- package/src/native/command-progress.mjs +64 -0
- package/src/native/command-shell.mjs +9 -0
- package/src/native/command-workspace-observation.mjs +36 -0
- package/src/native/component-tools.mjs +280 -0
- package/src/native/context-counter.mjs +133 -0
- package/src/native/context-markers.mjs +51 -0
- package/src/native/context-profile.mjs +72 -0
- package/src/native/context-status.mjs +27 -0
- package/src/native/conversation-command-control.mjs +133 -0
- package/src/native/delivery-timeline.mjs +39 -0
- package/src/native/dsh-background-ownership.mjs +102 -0
- package/src/native/dsh-managed-ownership.mjs +23 -0
- package/src/native/dsh-turn-contract.mjs +3 -0
- package/src/native/dsh-turn-ownership.mjs +54 -0
- package/src/native/dsh-turn-runtime.mjs +498 -0
- package/src/native/e2b-background-supervisor.mjs +347 -0
- package/src/native/e2b-background.mjs +368 -0
- package/src/native/e2b-command-state.mjs +40 -0
- package/src/native/e2b-command-tools.mjs +486 -0
- package/src/native/e2b-sandbox-owner.mjs +227 -0
- package/src/native/hook-executor.mjs +249 -0
- package/src/native/instruction-assembly.mjs +60 -0
- package/src/native/instruction-context.mjs +54 -0
- package/src/native/local-background-supervisor.mjs +108 -0
- package/src/native/local-background.mjs +207 -0
- package/src/native/local-command-evidence.mjs +57 -0
- package/src/native/local-command-policy-worker.mjs +197 -0
- package/src/native/local-command-policy.mjs +46 -0
- package/src/native/local-command-state.mjs +177 -0
- package/src/native/local-command-tools.mjs +302 -0
- package/src/native/local-execution-profile.mjs +36 -0
- package/src/native/local-output-policy.mjs +92 -0
- package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
- package/src/native/local-process-bin/darwin-x64/launcher +0 -0
- package/src/native/local-process-bin/linux-arm64/launcher +0 -0
- package/src/native/local-process-bin/linux-x64/launcher +0 -0
- package/src/native/local-process-launcher.c +183 -0
- package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
- package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
- package/src/native/local-process-licenses/NOTICE +21 -0
- package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
- package/src/native/local-process-manifest.json +32 -0
- package/src/native/local-process.mjs +246 -0
- package/src/native/local-resource-profile.mjs +32 -0
- package/src/native/memory-candidate.mjs +121 -0
- package/src/native/memory-extraction-run.mjs +60 -0
- package/src/native/memory-extraction-tools.mjs +116 -0
- package/src/native/memory-recall.mjs +65 -0
- package/src/native/memory-store.mjs +334 -0
- package/src/native/memory-tools.mjs +329 -0
- package/src/native/model-anthropic-cache.mjs +49 -0
- package/src/native/model-anthropic-tool-search.mjs +85 -0
- package/src/native/model-anthropic.mjs +753 -0
- package/src/native/model-chat-usage.mjs +24 -0
- package/src/native/model-inline-reasoning.mjs +111 -0
- package/src/native/model-media.mjs +174 -0
- package/src/native/model-normalize.mjs +228 -0
- package/src/native/model-openai-chat.mjs +561 -0
- package/src/native/model-port.mjs +361 -0
- package/src/native/model-provider-compat.mjs +129 -0
- package/src/native/model-replay.mjs +153 -0
- package/src/native/model-request.mjs +15 -0
- package/src/native/model-responses.mjs +1205 -0
- package/src/native/model-sse.mjs +171 -0
- package/src/native/model-surface-host.mjs +27 -0
- package/src/native/model-tool-search-policy.mjs +16 -0
- package/src/native/model-transport-observation.mjs +27 -0
- package/src/native/owned-child.mjs +114 -0
- package/src/native/owned-command.mjs +106 -0
- package/src/native/python-syntax-worker.mjs +60 -0
- package/src/native/python-syntax.mjs +50 -0
- package/src/native/read-interruption.mjs +16 -0
- package/src/native/result-stream.mjs +63 -0
- package/src/native/ruff-ast.mjs +95 -0
- package/src/native/run-observation.mjs +128 -0
- package/src/native/session-delivery-journal.mjs +96 -0
- package/src/native/session-delivery.mjs +434 -0
- package/src/native/shell-analysis.mjs +50 -0
- package/src/native/skill-disclosure.mjs +404 -0
- package/src/native/skill-events.mjs +164 -0
- package/src/native/skill-tools.mjs +813 -0
- package/src/native/source-page.mjs +73 -0
- package/src/native/sqlite-session-inbox.mjs +42 -0
- package/src/native/sqlite-store.mjs +617 -0
- package/src/native/tool-authority.mjs +99 -0
- package/src/native/tool-guidance.mjs +12 -0
- package/src/native/tool-observation.mjs +75 -0
- package/src/native/tool-states.mjs +14 -0
- package/src/native/verification-authority.mjs +37 -0
- package/src/native/web-tools.mjs +643 -0
- package/src/native/workspace-checkpoints.mjs +312 -0
- package/src/native/workspace-context.mjs +61 -0
- package/src/native/workspace-display.mjs +39 -0
- package/src/native/workspace-edit-match.mjs +19 -0
- package/src/native/workspace-edit-worker.mjs +15 -0
- package/src/native/workspace-editing.mjs +447 -0
- package/src/native/workspace-image.mjs +72 -0
- package/src/native/workspace-instructions.mjs +107 -0
- package/src/native/workspace-mutation-lock.mjs +74 -0
- package/src/native/workspace-patch-worker.mjs +17 -0
- package/src/native/workspace-patch.mjs +17 -0
- package/src/native/workspace-paths.mjs +225 -0
- package/src/native/workspace-state.mjs +272 -0
- package/src/native/workspace-subject-observer.mjs +84 -0
- package/src/native/workspace-tools.mjs +1740 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
// Native Skills builtin tools — bounded formal definitions and ToolPort.
|
|
2
|
+
//
|
|
3
|
+
// Security contract: the internal definition map is deep-cloned from the
|
|
4
|
+
// public definitions; the public array returns independent copies. Every
|
|
5
|
+
// execute() validates request.definition against the internal formal via
|
|
6
|
+
// canonical comparison (before authorization and after every await). The
|
|
7
|
+
// full request object is re-read after each await — not destructured locals
|
|
8
|
+
// — so any drift in runId/turn/build/fence/signal/definition/call is detected.
|
|
9
|
+
// forAgent validates authority.scope/sessionId against the binding identity.
|
|
10
|
+
// An uncertain backend result (after side effects) returns Native status
|
|
11
|
+
// 'uncertain', never 'error' with executed:false.
|
|
12
|
+
|
|
13
|
+
import { canonical, copy, invariant, scopeKey, toolInputRejection } from '@kingsoftcloud/agentkit-harness-core';
|
|
14
|
+
import { nativeSkillEventEvidence } from './skill-events.mjs';
|
|
15
|
+
import { realpath, lstat, stat, readdir } from 'node:fs/promises';
|
|
16
|
+
import { join, relative, sep } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const SKILL_TOOL_CONTRACT = 'agentkit.native-skill-tools/v3';
|
|
19
|
+
|
|
20
|
+
function uncertainOutcome(content) {
|
|
21
|
+
const result = { ...content, uncertain: true };
|
|
22
|
+
delete result.executed;
|
|
23
|
+
return { status: 'uncertain', content: result };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- skill name normalization (ksadk/skills/runtime/base.py) ------------------
|
|
27
|
+
|
|
28
|
+
export function normalizeSkillNames(skillNames) {
|
|
29
|
+
if (skillNames == null) return [];
|
|
30
|
+
const rawValues = Array.isArray(skillNames) ? skillNames.map(String) : [String(skillNames)];
|
|
31
|
+
const normalized = [], seen = new Set();
|
|
32
|
+
for (const raw of rawValues) {
|
|
33
|
+
for (const part of String(raw).split(',')) {
|
|
34
|
+
const name = part.trim();
|
|
35
|
+
const key = name.toLowerCase();
|
|
36
|
+
if (!name || seen.has(key)) continue;
|
|
37
|
+
seen.add(key);
|
|
38
|
+
normalized.push(name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- skill matching (ksadk/skills/runtime/registry.py) ------------------------
|
|
45
|
+
|
|
46
|
+
function tokenize(text) {
|
|
47
|
+
return new Set(text.toLowerCase().split(/[^0-9a-zA-Z一-鿿]+/).filter(t => t.length >= 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function metadataScore(skill, normalizedPrompt, promptTokens) {
|
|
51
|
+
if (skill.name && skill.name.toLowerCase().includes(normalizedPrompt.trim())) return [90, 'name'];
|
|
52
|
+
if (skill.name && normalizedPrompt.includes(skill.name.toLowerCase())) return [90, 'name'];
|
|
53
|
+
for (const alias of skill.aliases ?? []) {
|
|
54
|
+
if (alias && normalizedPrompt.includes(alias.toLowerCase())) return [80, 'alias'];
|
|
55
|
+
}
|
|
56
|
+
for (const tag of skill.tags ?? []) {
|
|
57
|
+
const tagKey = (tag || '').toLowerCase();
|
|
58
|
+
if (tagKey && (normalizedPrompt.includes(tagKey) || promptTokens.has(tagKey))) return [65, 'tag'];
|
|
59
|
+
}
|
|
60
|
+
const text = [skill.description ?? '', ...(skill.examples ?? [])].join(' ').toLowerCase();
|
|
61
|
+
const skillTokens = tokenize(text);
|
|
62
|
+
const overlap = [...promptTokens].filter(t => skillTokens.has(t));
|
|
63
|
+
if (overlap.length) return [Math.min(55, 20 + overlap.length * 10), 'metadata'];
|
|
64
|
+
return [0, ''];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function matchSkillRefs(skillRefs, prompt, { skillNames = null, limit = null } = {}) {
|
|
68
|
+
const requestedNames = new Set(normalizeSkillNames(skillNames).map(n => n.toLowerCase()));
|
|
69
|
+
const normalizedPrompt = (prompt || '').toLowerCase();
|
|
70
|
+
const promptTokens = tokenize(normalizedPrompt);
|
|
71
|
+
const matches = [], seen = new Set();
|
|
72
|
+
for (const skill of skillRefs) {
|
|
73
|
+
if (!skill.name) continue;
|
|
74
|
+
const skillName = skill.name.toLowerCase();
|
|
75
|
+
let score, reason;
|
|
76
|
+
if (requestedNames.size) {
|
|
77
|
+
if (!requestedNames.has(skillName)) continue;
|
|
78
|
+
score = 100; reason = 'exact_name';
|
|
79
|
+
} else {
|
|
80
|
+
[score, reason] = metadataScore(skill, normalizedPrompt, promptTokens);
|
|
81
|
+
if (score <= 0) continue;
|
|
82
|
+
}
|
|
83
|
+
const key = skill.cacheKey || skill.skillId || skill.name;
|
|
84
|
+
if (seen.has(key)) continue;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
matches.push({ skill, score, reason });
|
|
87
|
+
}
|
|
88
|
+
matches.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
|
|
89
|
+
return limit != null ? matches.slice(0, Math.max(0, limit)) : matches;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- manifest helpers (ksadk/skills/tool_defs.py) -----------------------------
|
|
93
|
+
|
|
94
|
+
function singleLine(value, limit = 240) {
|
|
95
|
+
const text = String(value || '').trim().split(/\s+/).join(' ');
|
|
96
|
+
return text.slice(0, limit).replace(/\s+$/, '');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function skillManifestItem(skill, spaceId) {
|
|
100
|
+
const item = {
|
|
101
|
+
name: String(skill.name || '').trim(),
|
|
102
|
+
description: singleLine(skill.description),
|
|
103
|
+
version: String(skill.version || '').trim(),
|
|
104
|
+
space_id: String(spaceId || '').trim(),
|
|
105
|
+
};
|
|
106
|
+
if (skill.aliases?.length) item.aliases = singleLine(skill.aliases.join(', '));
|
|
107
|
+
if (skill.tags?.length) item.tags = singleLine(skill.tags.join(', '));
|
|
108
|
+
return item;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function selectPublicSkillRefs(skillRefs, allowlist) {
|
|
112
|
+
if (!allowlist?.length) return skillRefs.filter(skill => skill.name);
|
|
113
|
+
const allowed = new Set(allowlist.map(n => n.toLowerCase()));
|
|
114
|
+
return skillRefs.filter(skill => skill.name && allowed.has(skill.name.toLowerCase()));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- formal tool definitions -------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const text = (desc, max = 1024) => ({ type: 'string', description: desc, maxLength: max });
|
|
120
|
+
const str = desc => ({ type: 'string', description: desc });
|
|
121
|
+
|
|
122
|
+
const TOOL_DEFINITIONS = [
|
|
123
|
+
{ name: 'list_skill_spaces', description: 'List visible Skill Spaces with id/name/description.',
|
|
124
|
+
parameters: { type: 'object', properties: {}, required: [], additionalProperties: false },
|
|
125
|
+
readOnly: true, concurrentSafe: true, permissions: [], timeoutMs: 15000,
|
|
126
|
+
contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal' },
|
|
127
|
+
{ name: 'list_skills', description: 'List skills discoverable from configured Skill Spaces.',
|
|
128
|
+
parameters: { type: 'object', properties: { space_id: str('Limit to one bound Skill Space.') }, required: [], additionalProperties: false },
|
|
129
|
+
readOnly: true, concurrentSafe: true, permissions: [], timeoutMs: 30000,
|
|
130
|
+
contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal' },
|
|
131
|
+
{ name: 'load_skill', description: "Download and load a skill's SKILL.md instructions.",
|
|
132
|
+
parameters: { type: 'object', properties: { skill_name: text('The skill name to load.'), space_id: str('Limit to one bound Skill Space.') }, required: ['skill_name'], additionalProperties: false },
|
|
133
|
+
readOnly: true, concurrentSafe: true, permissions: [], timeoutMs: 30000,
|
|
134
|
+
contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal' },
|
|
135
|
+
{ name: 'preview_skill', description: 'Return manifest-level info for a skill without downloading.',
|
|
136
|
+
parameters: { type: 'object', properties: { skill_name: text('The skill name to preview.'), space_id: str('Limit to one bound Skill Space.') }, required: ['skill_name'], additionalProperties: false },
|
|
137
|
+
readOnly: true, concurrentSafe: true, permissions: [], timeoutMs: 15000,
|
|
138
|
+
contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal' },
|
|
139
|
+
{ name: 'search_skills', description: 'Search skills by name, aliases, tags, description.',
|
|
140
|
+
parameters: { type: 'object', properties: { query: text('The search query.'), max_results: { type: 'integer', description: 'Max results (default 10).', minimum: 1, maximum: 50 }, space_id: str('Limit to one bound Skill Space.') }, required: ['query'], additionalProperties: false },
|
|
141
|
+
readOnly: true, concurrentSafe: true, permissions: [], timeoutMs: 15000,
|
|
142
|
+
contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal' },
|
|
143
|
+
{ name: 'execute_skills', description: 'Execute a workflow through the configured Skill Runtime. Has side effects.',
|
|
144
|
+
parameters: { type: 'object', properties: { workflow_prompt: text('The workflow prompt.', 8192), skill_names: { type: 'array', items: { type: 'string' }, description: 'Skill names to execute with.' }, approval: { type: 'object', description: 'Optional approval metadata.', additionalProperties: true } }, required: ['workflow_prompt'], additionalProperties: false },
|
|
145
|
+
readOnly: false, concurrentSafe: false, permissions: [], timeoutMs: 900000,
|
|
146
|
+
contextRole: 'none', observationRole: 'none', progressObservation: false, contextRetention: 'normal' },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
// --- identity validation -----------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function validateRequestIdentity(request) {
|
|
152
|
+
const { scope, runId, sessionId, turnId, buildId, parentRunId, taskChainId, operationKey, executionFence, signal } = request;
|
|
153
|
+
if (!(scope && typeof scope === 'object' && !Array.isArray(scope))) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'scope is required' };
|
|
154
|
+
try { scopeKey(scope); } catch { return { code: 'NATIVE_SKILL_IDENTITY_INVALID', message: 'scope must have four non-empty domain strings' }; }
|
|
155
|
+
if (typeof runId !== 'string' || !runId.trim()) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'runId is required' };
|
|
156
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'sessionId is required' };
|
|
157
|
+
if (typeof turnId !== 'string' || !turnId.trim()) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'turnId is required' };
|
|
158
|
+
if (typeof buildId !== 'string' || !buildId.trim()) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'buildId is required' };
|
|
159
|
+
if (parentRunId !== null && (typeof parentRunId !== 'string' || !parentRunId.trim())) return { code: 'NATIVE_SKILL_IDENTITY_INVALID', message: 'parentRunId must be null or non-empty' };
|
|
160
|
+
if (typeof taskChainId !== 'string' || !taskChainId.trim()) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'taskChainId is required' };
|
|
161
|
+
if (typeof operationKey !== 'string' || !operationKey.trim()) return { code: 'NATIVE_SKILL_IDENTITY_MISSING', message: 'operationKey is required' };
|
|
162
|
+
if (!Number.isSafeInteger(executionFence) || executionFence <= 0) return { code: 'NATIVE_SKILL_IDENTITY_INVALID', message: 'executionFence must be a positive integer' };
|
|
163
|
+
if (!(signal instanceof AbortSignal)) return { code: 'NATIVE_SKILL_IDENTITY_INVALID', message: 'signal must be an AbortSignal' };
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Snapshot the full request identity + call + definition into a sealed
|
|
168
|
+
* string for drift comparison. Canonicalizes ALL enumerable own keys of the
|
|
169
|
+
* request body excluding `signal` (which is not JSON-cloneable). This
|
|
170
|
+
* catches drift in any field — budget, interaction, tokens, tools, or
|
|
171
|
+
* future fields — without manually enumerating a subset. */
|
|
172
|
+
function snapshotRequest(request, internalDefinition) {
|
|
173
|
+
const body = Object.create(null);
|
|
174
|
+
for (const key of Object.keys(request)) {
|
|
175
|
+
if (key === 'signal') continue;
|
|
176
|
+
const value = request[key];
|
|
177
|
+
if (value !== undefined) body[key] = copy(value);
|
|
178
|
+
}
|
|
179
|
+
return canonical({ body, internalDefinition });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Race an async operation against an AbortSignal. Removes the abort
|
|
183
|
+
* listener in finally to prevent leaks. A late rejection from the
|
|
184
|
+
* operation is observed via the returned promise. */
|
|
185
|
+
function raceAbort(signal, operation) {
|
|
186
|
+
if (signal.aborted) return Promise.reject(Object.assign(new Error('REQUEST_CANCELLED'), { code: 'REQUEST_CANCELLED' }));
|
|
187
|
+
let listener = null;
|
|
188
|
+
const stopped = new Promise((_, reject) => {
|
|
189
|
+
// Let a result already settled by the dispatching callback retain known
|
|
190
|
+
// remote identity. A callback that never settles still loses this race.
|
|
191
|
+
listener = () => queueMicrotask(() => reject(Object.assign(new Error('REQUEST_CANCELLED'), { code: 'REQUEST_CANCELLED' })));
|
|
192
|
+
signal.addEventListener('abort', listener, { once: true });
|
|
193
|
+
});
|
|
194
|
+
let pending;
|
|
195
|
+
try { pending = Promise.resolve(operation()); } catch (error) { pending = Promise.reject(error); }
|
|
196
|
+
return Promise.race([pending, stopped]).finally(() => {
|
|
197
|
+
if (listener) signal.removeEventListener('abort', listener);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function frozenCopy(value) {
|
|
202
|
+
const result = copy(value);
|
|
203
|
+
const freeze = item => {
|
|
204
|
+
if (item && typeof item === 'object') { Object.values(item).forEach(freeze); Object.freeze(item); }
|
|
205
|
+
return item;
|
|
206
|
+
};
|
|
207
|
+
return freeze(result);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- ToolPort factory --------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export function createNativeSkillTools({
|
|
213
|
+
serviceClient, loadSkillFn, executeWorkflow, locateWorkflow, authorize,
|
|
214
|
+
skillSpaceIds = [], publicSpaceIds = [], publicAllowlist = [], boundSkillNames = [], manifestLimit = 30,
|
|
215
|
+
binding = {},
|
|
216
|
+
} = {}) {
|
|
217
|
+
invariant(typeof authorize === 'function', 'native_skill_authorize_required');
|
|
218
|
+
|
|
219
|
+
const sealedSpaceIds = copy(skillSpaceIds);
|
|
220
|
+
const sealedPublicSpaceIds = copy(publicSpaceIds);
|
|
221
|
+
const sealedPublicAllowlist = copy(publicAllowlist);
|
|
222
|
+
const sealedBoundSkillNames = new Set(copy(boundSkillNames).map(n => n.toLowerCase()));
|
|
223
|
+
const allSpaceIds = [...sealedSpaceIds, ...sealedPublicSpaceIds];
|
|
224
|
+
const readTransport = createSkillReadTransport({ serviceClient, loadSkillFn, skillSpaceIds: sealedSpaceIds,
|
|
225
|
+
publicSpaceIds: sealedPublicSpaceIds, publicAllowlist: sealedPublicAllowlist, manifestLimit });
|
|
226
|
+
|
|
227
|
+
// Fix #1, #4: Internal definition map is an independent deep copy. The
|
|
228
|
+
// public definitions array returns separate copies so mutating the public
|
|
229
|
+
// array or a returned definition object does not affect the internal map.
|
|
230
|
+
const internalDefinitions = copy(TOOL_DEFINITIONS);
|
|
231
|
+
const internalDefinitionMap = new Map(internalDefinitions.map(d => [d.name, copy(d)]));
|
|
232
|
+
const publicDefinitions = copy(TOOL_DEFINITIONS);
|
|
233
|
+
|
|
234
|
+
const sealedBinding = copy(binding);
|
|
235
|
+
|
|
236
|
+
// --- space validation (fix #5) -------------------------------------------
|
|
237
|
+
|
|
238
|
+
function validateSkillNames(names) {
|
|
239
|
+
if (!names.length || !sealedBoundSkillNames.size) return null;
|
|
240
|
+
const unbound = names.filter(n => !sealedBoundSkillNames.has(n.toLowerCase()));
|
|
241
|
+
return unbound.length ? `skill names not bound: ${unbound.join(', ')}` : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- guard closure (fix #3) ------------------------------------------------
|
|
245
|
+
// A guard function that re-reads the original request object and compares
|
|
246
|
+
// against the sealed snapshot. Used before every service/loader/backend call.
|
|
247
|
+
|
|
248
|
+
function makeGuard(request, internalDef, assertCurrent) {
|
|
249
|
+
const sealed = snapshotRequest(request, internalDef);
|
|
250
|
+
const originalSignal = request.signal;
|
|
251
|
+
return {
|
|
252
|
+
check() {
|
|
253
|
+
if (originalSignal.aborted) return { code: 'REQUEST_CANCELLED', message: 'Cancelled during execution' };
|
|
254
|
+
try {
|
|
255
|
+
if (request.signal !== originalSignal || snapshotRequest(request, internalDef) !== sealed)
|
|
256
|
+
return { code: 'NATIVE_SKILL_DRIFT', message: 'Request identity, call, definition or signal drifted' };
|
|
257
|
+
const verdict = assertCurrent(request);
|
|
258
|
+
if (verdict && typeof verdict.then === 'function') Promise.resolve(verdict).catch(() => {});
|
|
259
|
+
if (verdict !== true)
|
|
260
|
+
return { code: 'NATIVE_SKILL_AUTHORITY_EXPIRED', message: 'The original tool intent is no longer current' };
|
|
261
|
+
if (originalSignal.aborted) return { code: 'REQUEST_CANCELLED', message: 'Cancelled during authorization check' };
|
|
262
|
+
if (request.signal !== originalSignal || snapshotRequest(request, internalDef) !== sealed)
|
|
263
|
+
return { code: 'NATIVE_SKILL_DRIFT', message: 'Request changed inside authorization check' };
|
|
264
|
+
} catch {
|
|
265
|
+
return { code: 'NATIVE_SKILL_AUTHORITY_EXPIRED', message: 'The original tool intent cannot be verified' };
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
},
|
|
269
|
+
signal: originalSignal,
|
|
270
|
+
assertCurrent() {
|
|
271
|
+
const failure = this.check();
|
|
272
|
+
if (failure) throw new SkillToolError(failure.code, failure.message);
|
|
273
|
+
return true;
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- execute_skills (fix #4: effectStarted, uncertain, approval) ------------
|
|
279
|
+
|
|
280
|
+
async function executeSkills({ workflow_prompt: workflowPrompt, skill_names: skillNames, approval } = {}, ctx) {
|
|
281
|
+
const guard = ctx.guard;
|
|
282
|
+
const prompt = String(workflowPrompt || '').trim();
|
|
283
|
+
if (!prompt) return { ok: false, error_message: 'workflow_prompt is required' };
|
|
284
|
+
if (typeof executeWorkflow !== 'function') {
|
|
285
|
+
return { ok: false, error_type: 'skill_runtime_unavailable', error_message: 'Skill Runtime backend is not configured on this Host.' };
|
|
286
|
+
}
|
|
287
|
+
const names = normalizeSkillNames(skillNames);
|
|
288
|
+
const nameError = validateSkillNames(names);
|
|
289
|
+
if (nameError) return { ok: false, error_type: 'SKILL_NAME_NOT_BOUND', error_message: nameError };
|
|
290
|
+
const g = guard.check(); if (g) return { ok: false, error_type: g.code, error_message: g.message };
|
|
291
|
+
|
|
292
|
+
// Once the workflow is dispatched, side effects may have occurred.
|
|
293
|
+
// Any subsequent abort/throw/guard-failure returns uncertain, not error.
|
|
294
|
+
let effectStarted = false;
|
|
295
|
+
let result;
|
|
296
|
+
let runtimeLocator = {};
|
|
297
|
+
try {
|
|
298
|
+
const g2 = guard.check(); if (g2) return { ok: false, error_type: g2.code, error_message: g2.message };
|
|
299
|
+
// effectStarted is set only immediately before the real backend call,
|
|
300
|
+
// not before validation or guard checks.
|
|
301
|
+
effectStarted = true;
|
|
302
|
+
ctx.effectStarted = true;
|
|
303
|
+
const workflowRequest = {
|
|
304
|
+
workflowPrompt: prompt, skillNames: names, skillSpaceIds: allSpaceIds, env: {}, timeout: 900,
|
|
305
|
+
signal: guard.signal, scope: ctx.scope, runId: ctx.runId, sessionId: ctx.sessionId,
|
|
306
|
+
turnId: ctx.turnId, buildId: ctx.buildId, parentRunId: ctx.parentRunId,
|
|
307
|
+
taskChainId: ctx.taskChainId, operationKey: ctx.operationKey,
|
|
308
|
+
executionFence: ctx.executionFence, callId: ctx.callId, call: ctx.call,
|
|
309
|
+
lockedDefinition: copy(ctx.lockedDefinition), ...(approval === undefined ? {} : { approval }), assertCurrent: () => guard.assertCurrent(),
|
|
310
|
+
};
|
|
311
|
+
if (typeof locateWorkflow === 'function') {
|
|
312
|
+
const locator = locateWorkflow(workflowRequest);
|
|
313
|
+
if (locator && typeof locator.then === 'function') Promise.resolve(locator).catch(() => {});
|
|
314
|
+
invariant(locator && typeof locator.then !== 'function' && typeof locator.runtime_id === 'string' && locator.runtime_id.length <= 4096
|
|
315
|
+
&& typeof locator.task_id === 'string' && /^[a-f0-9]{64}$/u.test(locator.task_id), 'SKILL_RUNTIME_LOCATOR_INVALID');
|
|
316
|
+
runtimeLocator = { runtime_id: locator.runtime_id, task_id: locator.task_id };
|
|
317
|
+
guard.assertCurrent();
|
|
318
|
+
}
|
|
319
|
+
result = await raceAbort(guard.signal, () => executeWorkflow(workflowRequest));
|
|
320
|
+
if (runtimeLocator.runtime_id && result && typeof result === 'object') {
|
|
321
|
+
invariant((result.runtime_id ?? runtimeLocator.runtime_id) === runtimeLocator.runtime_id
|
|
322
|
+
&& (result.task_id ?? runtimeLocator.task_id) === runtimeLocator.task_id, 'SKILL_RUNTIME_LOCATOR_CHANGED');
|
|
323
|
+
result = { ...result, ...runtimeLocator };
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// After effectStarted, any throw is uncertain — the workflow may have
|
|
327
|
+
// had side effects before the throw. This includes approval_required:
|
|
328
|
+
// the workflow may have started side effects before requesting approval,
|
|
329
|
+
// so we cannot prove no side effects occurred.
|
|
330
|
+
if (effectStarted) {
|
|
331
|
+
return { ...runtimeLocator, ok: false, status: 'uncertain', uncertain: true,
|
|
332
|
+
error_type: error.code === 'approval_required' || error.approvalRequired ? 'invalid_skill_approval' : 'skill_runtime_uncertain',
|
|
333
|
+
error_message: error.message || 'Skill runtime outcome is unknown; the workflow may have side effects.' };
|
|
334
|
+
}
|
|
335
|
+
return { ok: false, error_type: error.code || error.name || 'Error', error_message: error.message || String(error) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Post-workflow guard: if the request drifted after the workflow completed,
|
|
339
|
+
// the result is uncertain (side effects may have happened).
|
|
340
|
+
const g3 = guard.check();
|
|
341
|
+
if (g3) return { ...runtimeLocator, ok: false, status: 'uncertain', error_type: g3.code, error_message: g3.message, uncertain: true,
|
|
342
|
+
...nativeSkillEventEvidence(result?.skill_events),
|
|
343
|
+
runtime_id: typeof result?.runtime_id === 'string' ? result.runtime_id : runtimeLocator.runtime_id ?? '', exit_code: result?.exit_code ?? null };
|
|
344
|
+
|
|
345
|
+
// Post-workflow abort: uncertain, not error+executed:false.
|
|
346
|
+
if (guard.signal.aborted) {
|
|
347
|
+
return { ok: false, status: 'uncertain', error_type: 'skill_runtime_uncertain',
|
|
348
|
+
...nativeSkillEventEvidence(result?.skill_events),
|
|
349
|
+
error_message: 'Skill workflow completed but the request was cancelled; the outcome may be unknown.',
|
|
350
|
+
uncertain: true, runtime_id: result?.runtime_id || '', exit_code: result?.exit_code ?? null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!result || typeof result !== 'object') {
|
|
354
|
+
return { ...runtimeLocator, ok: false, status: 'uncertain', uncertain: true, error_type: 'invalid_skill_runtime_result',
|
|
355
|
+
error_message: `skill runtime returned ${typeof result}, expected object` };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Approval-required result from backend: cannot prove no side effects —
|
|
359
|
+
// the backend may have started side effects before requesting approval.
|
|
360
|
+
// Return uncertain, not approval_required.
|
|
361
|
+
if (result.approval_required || result.status === 'approval_required') {
|
|
362
|
+
return { ...runtimeLocator, ok: false, status: 'uncertain', uncertain: true, error_type: 'invalid_skill_approval',
|
|
363
|
+
interactionId: typeof result.interactionId === 'string' ? result.interactionId : '',
|
|
364
|
+
error_message: result.error_message || 'Skill runtime requested approval after potential side effects.' };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Uncertain result.
|
|
368
|
+
if (result.uncertain === true || result.status === 'uncertain' ||
|
|
369
|
+
result.executed === false && result.ok === true ||
|
|
370
|
+
(!Number.isSafeInteger(result.exit_code) && result.executed !== false)) {
|
|
371
|
+
return { ...runtimeLocator, ok: false, status: 'uncertain', error_type: 'skill_runtime_uncertain',
|
|
372
|
+
error_message: result.error_message || 'Skill runtime outcome is unknown.',
|
|
373
|
+
uncertain: true, runtime_id: result.runtime_id || '', exit_code: result.exit_code ?? null,
|
|
374
|
+
...nativeSkillEventEvidence(result.skill_events),
|
|
375
|
+
...(Array.isArray(result.command_ids) && result.command_ids.length <= 1000
|
|
376
|
+
&& result.command_ids.every(id => typeof id === 'string' && /^[a-f0-9]{64}$/u.test(id))
|
|
377
|
+
? { command_ids: copy(result.command_ids) } : {}) };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { ...result, executed: result.executed !== false,
|
|
381
|
+
ok: result.ok ?? (result.exit_code === 0 && !result.error_type && !result.timed_out) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- dispatch table ---------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
const handlers = {
|
|
387
|
+
...Object.fromEntries(nativeSkillReadToolDefinitions().map(definition => [definition.name, (_args, ctx) =>
|
|
388
|
+
readTransport.execute({ call: ctx.call, signal: ctx.guard.signal, assertCurrent: () => ctx.guard.assertCurrent() })])),
|
|
389
|
+
execute_skills: executeSkills,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
definitions: publicDefinitions,
|
|
394
|
+
forAgent(agent, authority = {}) {
|
|
395
|
+
// Fix #2: validate authority.scope and authority.sessionId against the
|
|
396
|
+
// binding. The Host injects the trusted scope/session; the tool request
|
|
397
|
+
// must match. Do not accept a request with no authority.
|
|
398
|
+
invariant(agent?.session?.id && typeof agent.session.id === 'string', 'native_skill_agent_session_required');
|
|
399
|
+
invariant(authority?.scope && typeof authority.scope === 'object', 'native_skill_authority_scope_required');
|
|
400
|
+
try { scopeKey(authority.scope); } catch { throw Object.assign(new Error('native_skill_authority_scope_invalid'), { code: 'native_skill_authority_scope_invalid' }); }
|
|
401
|
+
const sealedAuthorityScope = scopeKey(authority.scope);
|
|
402
|
+
invariant(typeof authority.sessionId === 'string' && authority.sessionId === agent.session.id,
|
|
403
|
+
'native_skill_authority_session_mismatch');
|
|
404
|
+
invariant(typeof authority.assertCurrent === 'function', 'native_skill_authority_guard_required');
|
|
405
|
+
const assertCurrent = authority.assertCurrent;
|
|
406
|
+
const sealedAuthoritySessionId = authority.sessionId;
|
|
407
|
+
invariant(!sealedBinding.agentId || sealedBinding.agentId === authority.scope.agentId, 'native_skill_binding_agent_mismatch');
|
|
408
|
+
invariant(!sealedBinding.agentRevision || sealedBinding.agentRevision === authority.agentRevision, 'native_skill_binding_revision_mismatch');
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
tools: {
|
|
412
|
+
async execute(request = {}) {
|
|
413
|
+
const { call, signal, scope, runId, sessionId, turnId, buildId,
|
|
414
|
+
parentRunId, taskChainId, operationKey, executionFence, definition } = request;
|
|
415
|
+
|
|
416
|
+
// --- validate call ---
|
|
417
|
+
if (!call?.name) return { status: 'error', content: { code: 'NATIVE_SKILL_CALL_MISSING', executed: false, message: 'call is missing or has no name' } };
|
|
418
|
+
const internalDef = internalDefinitionMap.get(call.name);
|
|
419
|
+
if (!internalDef) return { status: 'error', content: { code: 'native_skill_tool_unbound', executed: false, message: `tool "${call.name}" is not bound` } };
|
|
420
|
+
if (typeof call.callId !== 'string' || !call.callId.trim()) return { status: 'error', content: { code: 'NATIVE_SKILL_CALL_ID_MISSING', executed: false, message: 'callId is required' } };
|
|
421
|
+
|
|
422
|
+
// Fix #1: validate request.definition against the internal formal.
|
|
423
|
+
// If definition is provided, it must canonically match the internal
|
|
424
|
+
// formal. A forged or mutated definition is rejected.
|
|
425
|
+
{
|
|
426
|
+
if (!definition || canonical(definition) !== canonical(internalDef)) {
|
|
427
|
+
return { status: 'error', content: { code: 'NATIVE_SKILL_DEFINITION_FORGED', executed: false, message: 'request.definition does not match the locked formal definition' } };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- validate full request identity ---
|
|
432
|
+
const identityError = validateRequestIdentity(request);
|
|
433
|
+
if (identityError) return { status: 'error', content: { ...identityError, executed: false } };
|
|
434
|
+
|
|
435
|
+
// Fix #2: validate request scope/session against the authority binding.
|
|
436
|
+
try {
|
|
437
|
+
if (scopeKey(scope) !== sealedAuthorityScope) {
|
|
438
|
+
return { status: 'error', content: { code: 'NATIVE_SKILL_AUTHORITY_SCOPE_MISMATCH', executed: false, message: 'request scope does not match the bound authority scope' } };
|
|
439
|
+
}
|
|
440
|
+
} catch { return { status: 'error', content: { code: 'NATIVE_SKILL_IDENTITY_INVALID', executed: false, message: 'request scope is invalid' } }; }
|
|
441
|
+
if (sessionId !== sealedAuthoritySessionId) {
|
|
442
|
+
return { status: 'error', content: { code: 'NATIVE_SKILL_AUTHORITY_SESSION_MISMATCH', executed: false, message: 'request sessionId does not match the bound authority session' } };
|
|
443
|
+
}
|
|
444
|
+
if (sealedBinding.buildId && buildId !== sealedBinding.buildId)
|
|
445
|
+
return { status: 'error', content: { code: 'NATIVE_SKILL_BINDING_BUILD_MISMATCH', executed: false } };
|
|
446
|
+
|
|
447
|
+
// --- validate argument schema via toolInputRejection ---
|
|
448
|
+
const rejection = toolInputRejection(internalDef, call);
|
|
449
|
+
if (rejection) return { status: 'error', content: rejection };
|
|
450
|
+
|
|
451
|
+
// --- snapshot before authorization await (fix #3) ---
|
|
452
|
+
const guard = makeGuard(request, internalDef, assertCurrent);
|
|
453
|
+
if (guard.signal.aborted) return { status: 'error', content: { code: 'REQUEST_CANCELLED', executed: false, message: 'Cancelled before authorization' } };
|
|
454
|
+
const initial = guard.check();
|
|
455
|
+
if (initial) return { status: 'error', content: { ...initial, executed: false } };
|
|
456
|
+
|
|
457
|
+
// --- explicit authorization callback (raced against signal) ---
|
|
458
|
+
let decision;
|
|
459
|
+
try {
|
|
460
|
+
decision = await raceAbort(guard.signal, () => authorize(call.name, { call, signal: guard.signal, scope, runId, sessionId, turnId, buildId, parentRunId, taskChainId, operationKey, executionFence, definition: internalDef }));
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (guard.signal.aborted) return { status: 'error', content: { code: 'REQUEST_CANCELLED', executed: false, message: 'Cancelled during authorization' } };
|
|
463
|
+
return { status: 'error', content: { code: error.code || 'NATIVE_SKILL_ERROR', executed: false, message: error.message || String(error) } };
|
|
464
|
+
}
|
|
465
|
+
const g = guard.check();
|
|
466
|
+
if (g) return { status: 'error', content: { code: g.code, executed: false, message: g.message } };
|
|
467
|
+
if (decision !== 'allow') {
|
|
468
|
+
if (decision?.status === 'approval_required' && typeof decision.interactionId === 'string' && decision.interactionId.trim())
|
|
469
|
+
return { status: 'approval_required', interactionId: decision.interactionId, content: { executed: false } };
|
|
470
|
+
const code = decision?.code || 'STUDIO_NATIVE_SKILL_PERMISSION_DENIED';
|
|
471
|
+
const message = decision?.message || 'Skill tool not authorized';
|
|
472
|
+
return { status: 'error', content: { code, executed: false, message } };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- after-await drift guard (fix #3: re-read request) ---
|
|
476
|
+
// --- execute with full context + guard closure ---
|
|
477
|
+
const ctx = { signal: guard.signal, scope: copy(request.scope), runId: request.runId, sessionId: request.sessionId,
|
|
478
|
+
turnId: request.turnId, buildId: request.buildId, parentRunId: request.parentRunId,
|
|
479
|
+
taskChainId: request.taskChainId, operationKey: request.operationKey,
|
|
480
|
+
executionFence: request.executionFence, callId: request.call.callId,
|
|
481
|
+
call: copy(request.call), lockedDefinition: copy(internalDef), guard, effectStarted: false };
|
|
482
|
+
// Deep-copy and freeze the locator BEFORE any handler callback can
|
|
483
|
+
// mutate ctx.scope/call/etc. The backend receives ctx which may be
|
|
484
|
+
// mutated, but the locator retains the original sealed values.
|
|
485
|
+
const locator = frozenCopy({ scope: ctx.scope, runId: ctx.runId, sessionId: ctx.sessionId,
|
|
486
|
+
turnId: ctx.turnId, buildId: ctx.buildId, parentRunId: ctx.parentRunId,
|
|
487
|
+
taskChainId: ctx.taskChainId, operationKey: ctx.operationKey,
|
|
488
|
+
executionFence: ctx.executionFence, callId: ctx.callId, call: ctx.call });
|
|
489
|
+
try {
|
|
490
|
+
guard.signal.throwIfAborted();
|
|
491
|
+
// executeSkills owns the backend race and retains an already
|
|
492
|
+
// known runtime locator on cancellation. Racing it a second
|
|
493
|
+
// time here could discard that evidence before it returns.
|
|
494
|
+
const invoke = () => handlers[call.name](ctx.call.arguments ?? {}, ctx);
|
|
495
|
+
const result = await (call.name === 'execute_skills' ? invoke() : raceAbort(guard.signal, invoke));
|
|
496
|
+
const late = guard.check();
|
|
497
|
+
if (late) return ctx.effectStarted
|
|
498
|
+
? uncertainOutcome({ ...result, ...locator, error_type: late.code })
|
|
499
|
+
: { status: 'error', content: { code: late.code, executed: false } };
|
|
500
|
+
// Uncertain result.
|
|
501
|
+
if (result?.status === 'uncertain' || result?.uncertain === true) {
|
|
502
|
+
return uncertainOutcome({ ...result, ...locator });
|
|
503
|
+
}
|
|
504
|
+
// Approval-required: preserve.
|
|
505
|
+
if (result?.status === 'approval_required') {
|
|
506
|
+
return { status: 'approval_required', interactionId: result.interactionId, content: result };
|
|
507
|
+
}
|
|
508
|
+
// content.ok === false → status 'error'.
|
|
509
|
+
if (result && typeof result === 'object' && result.ok === false) {
|
|
510
|
+
return { status: 'error', content: { ...result, executed: result.executed ?? ctx.effectStarted } };
|
|
511
|
+
}
|
|
512
|
+
return { status: 'ok', content: result };
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (ctx.effectStarted) return { status: 'uncertain', content: { ...locator,
|
|
515
|
+
uncertain: true, error_type: 'skill_runtime_uncertain', error_message: error.message || String(error) } };
|
|
516
|
+
if (guard.signal.aborted) {
|
|
517
|
+
// Fix #4: if effectStarted was set by executeSkills, the abort
|
|
518
|
+
// after side effects is uncertain. For read-only tools, it's
|
|
519
|
+
// a clean cancel. We can't know here, but the handler already
|
|
520
|
+
// returned uncertain if needed. A throw reaching here from a
|
|
521
|
+
// read-only tool after abort is a clean cancel.
|
|
522
|
+
return { status: 'error', content: { code: 'REQUEST_CANCELLED', executed: false, message: 'Cancelled during execution' } };
|
|
523
|
+
}
|
|
524
|
+
return { status: 'error', content: { code: error.code || 'NATIVE_SKILL_ERROR', executed: false, message: error.message || String(error) } };
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- bounded script scanning -------------------------------------------------
|
|
534
|
+
|
|
535
|
+
const MAX_SCRIPT_FILES = 20;
|
|
536
|
+
const MAX_SCRIPT_BYTES = 64 * 1024;
|
|
537
|
+
|
|
538
|
+
async function scanScripts(rootDir) {
|
|
539
|
+
if (!rootDir || typeof rootDir !== 'string') return { hasScripts: false, files: [] };
|
|
540
|
+
try {
|
|
541
|
+
const resolvedRoot = await realpath(rootDir);
|
|
542
|
+
const scriptsDir = join(resolvedRoot, 'scripts');
|
|
543
|
+
const linfo = await lstat(scriptsDir).catch(() => null);
|
|
544
|
+
if (!linfo?.isDirectory() || linfo.isSymbolicLink()) return { hasScripts: false, files: [] };
|
|
545
|
+
const files = [];
|
|
546
|
+
await scanDir(scriptsDir, resolvedRoot, files);
|
|
547
|
+
return { hasScripts: files.length > 0, files: files.slice(0, MAX_SCRIPT_FILES) };
|
|
548
|
+
} catch { return { hasScripts: false, files: [] }; }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function scanDir(dir, root, files, depth = 0) {
|
|
552
|
+
if (depth > 3 || files.length >= MAX_SCRIPT_FILES) return;
|
|
553
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
554
|
+
for (const entry of entries) {
|
|
555
|
+
if (files.length >= MAX_SCRIPT_FILES) break;
|
|
556
|
+
const full = join(dir, entry.name);
|
|
557
|
+
if (entry.isSymbolicLink()) continue;
|
|
558
|
+
if (entry.isDirectory()) { await scanDir(full, root, files, depth + 1); continue; }
|
|
559
|
+
if (!entry.isFile()) continue;
|
|
560
|
+
const info = await stat(full).catch(() => null);
|
|
561
|
+
if (!info || info.size > MAX_SCRIPT_BYTES) continue;
|
|
562
|
+
files.push(relative(root, full).split(sep).join('/'));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export class SkillToolError extends Error {
|
|
567
|
+
constructor(code, message) { super(message); this.name = 'SkillToolError'; this.code = code; }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Formal schemas shared by the Native and DSH read-only adapters. */
|
|
571
|
+
export function nativeSkillReadToolDefinitions() { return copy(TOOL_DEFINITIONS.filter(definition => definition.readOnly)); }
|
|
572
|
+
|
|
573
|
+
/** Neutral Skill discovery/loading transport. The caller supplies its real
|
|
574
|
+
* authority; this layer neither creates an execution identity nor runs scripts.
|
|
575
|
+
* Download/cache publication is content addressed and guarded by the loader. */
|
|
576
|
+
export function createSkillReadTransport({ serviceClient, loadSkillFn, skillSpaceIds = [], publicSpaceIds = [],
|
|
577
|
+
publicAllowlist = [], manifestLimit = 30 } = {}) {
|
|
578
|
+
invariant(Number.isSafeInteger(manifestLimit) && manifestLimit > 0 && manifestLimit <= 4096, 'skill_read_manifest_limit_invalid');
|
|
579
|
+
const sealedSpaceIds = copy(skillSpaceIds), sealedPublicSpaceIds = copy(publicSpaceIds), sealedPublicAllowlist = copy(publicAllowlist);
|
|
580
|
+
const allSpaceIds = [...sealedSpaceIds, ...sealedPublicSpaceIds];
|
|
581
|
+
const userSpaceIdSet = new Set(sealedSpaceIds), publicSpaceIdSet = new Set(sealedPublicSpaceIds);
|
|
582
|
+
const formal = new Map(nativeSkillReadToolDefinitions().map(definition => [definition.name, definition]));
|
|
583
|
+
function resolveSpaceId(spaceId) {
|
|
584
|
+
if (spaceId == null) return { type: 'all' };
|
|
585
|
+
const key = String(spaceId).trim();
|
|
586
|
+
if (!key) return { error: 'space_id is empty' };
|
|
587
|
+
if (key === 'public') { if (!publicSpaceIdSet.size) return { error: 'public space is not configured' }; return { type: 'public' }; }
|
|
588
|
+
if (publicSpaceIdSet.has(key)) return { type: 'public', explicitId: key };
|
|
589
|
+
if (userSpaceIdSet.has(key)) return { type: 'user', id: key };
|
|
590
|
+
return { error: `space_id "${key}" is not a bound Skill Space` };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// --- space iteration -------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
async function iterRemoteSkills(spaceResolution, guard) {
|
|
596
|
+
if (!serviceClient) throw new SkillToolError('SKILL_SERVICE_UNAVAILABLE', 'Skill Service adapter is not configured');
|
|
597
|
+
if (spaceResolution.error) throw new SkillToolError('SKILL_SPACE_NOT_BOUND', spaceResolution.error);
|
|
598
|
+
const pairs = [];
|
|
599
|
+
const spaces = spaceResolution.type === 'all'
|
|
600
|
+
? [...sealedSpaceIds.map(id => ({ type: 'user', id })), ...(publicSpaceIdSet.size ? [{ type: 'public' }] : [])]
|
|
601
|
+
: [spaceResolution];
|
|
602
|
+
for (const space of spaces) {
|
|
603
|
+
guard.assertCurrent();
|
|
604
|
+
if (space.type === 'public') {
|
|
605
|
+
const listing = await serviceClient.listAvailablePremadeSkills({ signal: guard.signal, assertCurrent: () => guard.assertCurrent() });
|
|
606
|
+
guard.assertCurrent();
|
|
607
|
+
if (listing.truncated) throw new SkillToolError('SKILL_LISTING_INCOMPLETE', 'Public Skill listing is incomplete');
|
|
608
|
+
const active = selectPublicSkillRefs(listing.activeSkills ?? [], sealedPublicAllowlist);
|
|
609
|
+
const resolvedSpaceId = listing.spaceId || 'public';
|
|
610
|
+
if (!publicSpaceIdSet.has(resolvedSpaceId) || space.explicitId && space.explicitId !== resolvedSpaceId)
|
|
611
|
+
throw new SkillToolError('SKILL_SPACE_NOT_BOUND', 'Public directory does not match the bound and selected Skill Space');
|
|
612
|
+
for (const skill of active) pairs.push({ spaceId: resolvedSpaceId, skill });
|
|
613
|
+
} else {
|
|
614
|
+
const listing = await serviceClient.listSkillsBySpaceId(space.id, { signal: guard.signal, assertCurrent: () => guard.assertCurrent() });
|
|
615
|
+
guard.assertCurrent();
|
|
616
|
+
if (listing.truncated) throw new SkillToolError('SKILL_LISTING_INCOMPLETE', 'Skill Space listing is incomplete');
|
|
617
|
+
for (const skill of listing.activeSkills ?? []) pairs.push({ spaceId: space.id, skill });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return pairs;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// --- list_skill_spaces -----------------------------------------------------
|
|
624
|
+
|
|
625
|
+
async function listSkillSpaces(_args, { guard }) {
|
|
626
|
+
const configured = new Set(allSpaceIds);
|
|
627
|
+
let spaces = [];
|
|
628
|
+
if (serviceClient?.listSkillSpaces) {
|
|
629
|
+
try {
|
|
630
|
+
guard.assertCurrent();
|
|
631
|
+
spaces = parseSkillSpaces(await serviceClient.listSkillSpaces({ signal: guard.signal, assertCurrent: () => guard.assertCurrent() }));
|
|
632
|
+
} catch (error) { guard.assertCurrent(); /* configured spaces remain available during a service outage */ }
|
|
633
|
+
}
|
|
634
|
+
guard.assertCurrent();
|
|
635
|
+
for (const space of spaces) space.configured = configured.has(space.space_id);
|
|
636
|
+
if (!spaces.length) spaces = [...configured].map(sid => ({ space_id: sid, space_name: '', description: '', configured: true }));
|
|
637
|
+
return { ok: true, configured_space_ids: allSpaceIds, spaces };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function parseSkillSpaces(payload) {
|
|
641
|
+
const data = payload?.data || payload?.Data || {};
|
|
642
|
+
const raw = data.SkillSpaces || data.skill_spaces || data.Spaces || data.spaces || data.Items || data.items || [];
|
|
643
|
+
return raw.filter(item => item && typeof item === 'object').map(item => ({
|
|
644
|
+
space_id: String(item.SkillSpaceId || item.skill_space_id || item.SpaceId || item.space_id || ''),
|
|
645
|
+
space_name: String(item.SkillSpaceName || item.skill_space_name || item.SpaceName || item.space_name || ''),
|
|
646
|
+
description: String(item.Description || item.description || ''),
|
|
647
|
+
}));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// --- list_skills ------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
async function listSkills({ space_id: spaceId } = {}, ctx) {
|
|
653
|
+
const guard = ctx.guard;
|
|
654
|
+
const res = resolveSpaceId(spaceId);
|
|
655
|
+
const spaceIds = spaceId ? [spaceId] : allSpaceIds;
|
|
656
|
+
const g = guard.check(); if (g) return { ok: false, error_type: g.code, error_message: g.message };
|
|
657
|
+
try {
|
|
658
|
+
const pairs = await iterRemoteSkills(res, guard);
|
|
659
|
+
const g2 = guard.check(); if (g2) return { ok: false, error_type: g2.code, error_message: g2.message };
|
|
660
|
+
const seen = new Set(), skills = [];
|
|
661
|
+
for (const { spaceId: sid, skill } of pairs) {
|
|
662
|
+
if (!skill.name) continue;
|
|
663
|
+
const key = skill.name.toLowerCase();
|
|
664
|
+
if (seen.has(key)) continue;
|
|
665
|
+
seen.add(key);
|
|
666
|
+
skills.push(skillManifestItem(skill, sid));
|
|
667
|
+
if (skills.length >= manifestLimit) break;
|
|
668
|
+
}
|
|
669
|
+
return { ok: true, skills, truncated: pairs.length > skills.length, skill_space_ids: spaceIds, space_id: spaceId ?? null };
|
|
670
|
+
} catch (error) {
|
|
671
|
+
return { ok: false, error_type: error.code || error.name || 'Error', error_message: error.message || String(error), skill_space_ids: spaceIds, space_id: spaceId ?? null };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// --- load_skill / preview_skill --------------------------------------------
|
|
676
|
+
|
|
677
|
+
async function findSkillByName(skillName, spaceId, guard) {
|
|
678
|
+
const target = String(skillName || '').trim().toLowerCase();
|
|
679
|
+
if (!target) return null;
|
|
680
|
+
const pairs = await iterRemoteSkills(resolveSpaceId(spaceId), guard);
|
|
681
|
+
const g = guard.check(); if (g) throw new SkillToolError(g.code, g.message);
|
|
682
|
+
const available = [];
|
|
683
|
+
for (const { spaceId: sid, skill } of pairs) {
|
|
684
|
+
if (!skill.name) continue;
|
|
685
|
+
available.push(skill.name);
|
|
686
|
+
if (skill.name.trim().toLowerCase() === target) return { spaceId: sid, skill, available };
|
|
687
|
+
}
|
|
688
|
+
return { available };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function loadSkill({ skill_name: skillName, space_id: spaceId } = {}, ctx) {
|
|
692
|
+
const guard = ctx.guard;
|
|
693
|
+
const target = String(skillName || '').trim().toLowerCase();
|
|
694
|
+
if (!target) return { ok: false, error_message: 'skill_name is required' };
|
|
695
|
+
const g = guard.check(); if (g) return { ok: false, error_type: g.code, error_message: g.message };
|
|
696
|
+
try {
|
|
697
|
+
const found = await findSkillByName(skillName, spaceId, guard);
|
|
698
|
+
const g2 = guard.check(); if (g2) return { ok: false, error_type: g2.code, error_message: g2.message };
|
|
699
|
+
if (!found?.skill) return { ok: false, error_message: `Skill not found: ${skillName}`, space_id: spaceId ?? null, available_skills: found?.available ?? [] };
|
|
700
|
+
const { spaceId: sid, skill } = found;
|
|
701
|
+
if (typeof loadSkillFn !== 'function') {
|
|
702
|
+
return { ok: false, error_type: 'SKILL_LOADER_UNAVAILABLE',
|
|
703
|
+
error_message: 'No skill loader is configured on this Host.',
|
|
704
|
+
space_id: sid, skill_id: skill.skillId || '', version_id: skill.versionId || '' };
|
|
705
|
+
}
|
|
706
|
+
const g3 = guard.check(); if (g3) return { ok: false, error_type: g3.code, error_message: g3.message };
|
|
707
|
+
const loaded = await loadSkillFn({ skillId: skill.skillId, versionId: skill.versionId, name: skill.name, spaceId: sid,
|
|
708
|
+
contentHash: skill.contentHash || '', signal: guard.signal, assertCurrent: () => guard.assertCurrent() });
|
|
709
|
+
const g4 = guard.check(); if (g4) return { ok: false, error_type: g4.code, error_message: g4.message };
|
|
710
|
+
if (!loaded || !loaded.content) {
|
|
711
|
+
return { ok: false, error_type: 'SKILL_MANIFEST_MISSING',
|
|
712
|
+
error_message: `Skill "${skillName}" was found but its SKILL.md could not be loaded.`,
|
|
713
|
+
space_id: sid, skill_id: skill.skillId || '', version_id: skill.versionId || '' };
|
|
714
|
+
}
|
|
715
|
+
if (loaded.skillId !== skill.skillId || loaded.versionId !== skill.versionId || loaded.spaceId !== sid ||
|
|
716
|
+
skill.contentHash && loaded.contentHash !== skill.contentHash)
|
|
717
|
+
return { ok: false, error_type: 'SKILL_LOADER_IDENTITY_MISMATCH',
|
|
718
|
+
error_message: 'The loader did not attest the selected Skill identity, version and content hash.' };
|
|
719
|
+
const rootDir = loaded.rootDir || '';
|
|
720
|
+
const { hasScripts, files } = await scanScripts(rootDir);
|
|
721
|
+
const g5 = guard.check(); if (g5) return { ok: false, error_type: g5.code, error_message: g5.message };
|
|
722
|
+
return { ok: true, execution_context: 'outer_agent', name: skill.name, description: skill.description || '',
|
|
723
|
+
space_id: sid, skill_id: skill.skillId || '', version_id: skill.versionId || '', version: skill.version || '',
|
|
724
|
+
cache_hit: !!loaded.cacheHit, root_dir: rootDir, has_scripts_dir: hasScripts, script_files: files,
|
|
725
|
+
instructions: loaded.content, usage: "Read these instructions and complete the user's task in the outer agent unless the skill explicitly requires isolated execution." };
|
|
726
|
+
} catch (error) {
|
|
727
|
+
return { ok: false, error_type: error.code || error.name || 'Error', error_message: error.message || String(error) };
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function previewSkill({ skill_name: skillName, space_id: spaceId } = {}, ctx) {
|
|
732
|
+
const guard = ctx.guard;
|
|
733
|
+
const target = String(skillName || '').trim().toLowerCase();
|
|
734
|
+
if (!target) return { ok: false, error_message: 'skill_name is required' };
|
|
735
|
+
const g = guard.check(); if (g) return { ok: false, error_type: g.code, error_message: g.message };
|
|
736
|
+
try {
|
|
737
|
+
const found = await findSkillByName(skillName, spaceId, guard);
|
|
738
|
+
const g2 = guard.check(); if (g2) return { ok: false, error_type: g2.code, error_message: g2.message };
|
|
739
|
+
if (!found?.skill) return { ok: false, error_message: `Skill not found: ${skillName}`, space_id: spaceId ?? null, available_skills: found?.available ?? [] };
|
|
740
|
+
const { spaceId: sid, skill } = found;
|
|
741
|
+
return { ok: true, execution_context: 'manifest_preview', name: skill.name, description: skill.description || '',
|
|
742
|
+
space_id: sid, skill_id: skill.skillId || '', version_id: skill.versionId || '', version: skill.version || '',
|
|
743
|
+
cache_hit: false, has_scripts_dir: false, script_files: [], instructions: skill.description || '',
|
|
744
|
+
usage: 'Manifest-level preview. For full SKILL.md instructions, use load_skill.' };
|
|
745
|
+
} catch (error) {
|
|
746
|
+
return { ok: false, error_type: error.code || error.name || 'Error', error_message: error.message || String(error) };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// --- search_skills ----------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
async function searchSkills({ query, max_results: maxResults = 10, space_id: spaceId } = {}, ctx) {
|
|
753
|
+
const guard = ctx.guard;
|
|
754
|
+
const queryText = String(query || '').trim();
|
|
755
|
+
if (!queryText) return { ok: false, error_message: 'query is required' };
|
|
756
|
+
const g = guard.check(); if (g) return { ok: false, error_type: g.code, error_message: g.message };
|
|
757
|
+
try {
|
|
758
|
+
const pairs = await iterRemoteSkills(resolveSpaceId(spaceId), guard);
|
|
759
|
+
const g2 = guard.check(); if (g2) return { ok: false, error_type: g2.code, error_message: g2.message };
|
|
760
|
+
const spaceOf = {};
|
|
761
|
+
for (const { spaceId: sid, skill } of pairs) {
|
|
762
|
+
const key = skill.cacheKey || skill.skillId || skill.name;
|
|
763
|
+
if (key && !(key in spaceOf)) spaceOf[key] = sid;
|
|
764
|
+
}
|
|
765
|
+
const refs = pairs.map(({ skill }) => skill);
|
|
766
|
+
const matches = matchSkillRefs(refs, queryText, { limit: maxResults });
|
|
767
|
+
const results = matches.map(match => ({
|
|
768
|
+
name: match.skill.name, description: match.skill.description, version: match.skill.version,
|
|
769
|
+
score: match.score, reason: match.reason, aliases: match.skill.aliases ?? [], tags: match.skill.tags ?? [],
|
|
770
|
+
space_id: spaceOf[match.skill.cacheKey || match.skill.skillId || match.skill.name] || '',
|
|
771
|
+
}));
|
|
772
|
+
return { ok: true, query: queryText, space_id: spaceId ?? null, results };
|
|
773
|
+
} catch (error) {
|
|
774
|
+
return { ok: false, error_type: error.code || error.name || 'Error', error_message: error.message || String(error) };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
const handlers = { list_skill_spaces: listSkillSpaces, list_skills: listSkills, load_skill: loadSkill,
|
|
780
|
+
preview_skill: previewSkill, search_skills: searchSkills };
|
|
781
|
+
return { async execute(request) {
|
|
782
|
+
const definition = formal.get(request?.call?.name);
|
|
783
|
+
if (!definition) return { ok: false, error_type: 'skill_read_tool_unbound', executed: false };
|
|
784
|
+
const rejection = toolInputRejection(definition, request.call);
|
|
785
|
+
if (rejection) return { ...rejection, ok: false, executed: false };
|
|
786
|
+
const signal = request.signal, assertCurrent = request.assertCurrent;
|
|
787
|
+
invariant(signal instanceof AbortSignal && typeof assertCurrent === 'function', 'skill_read_authority_required');
|
|
788
|
+
const body = ({ signal: _, assertCurrent: __, ...json }) => canonical(json);
|
|
789
|
+
const sealed = body(request);
|
|
790
|
+
const stable = () => {
|
|
791
|
+
signal.throwIfAborted();
|
|
792
|
+
invariant(request.signal === signal && request.assertCurrent === assertCurrent && body(request) === sealed,
|
|
793
|
+
'skill_read_request_drifted');
|
|
794
|
+
};
|
|
795
|
+
const guard = { signal, check() {
|
|
796
|
+
try {
|
|
797
|
+
stable(); const verdict = assertCurrent();
|
|
798
|
+
if (verdict && typeof verdict.then === 'function') Promise.resolve(verdict).catch(() => {});
|
|
799
|
+
invariant(verdict === true, 'skill_read_authority_expired'); stable(); return null;
|
|
800
|
+
} catch (error) { return { code: signal.aborted ? 'REQUEST_CANCELLED' : error.code || 'skill_read_authority_expired',
|
|
801
|
+
message: signal.aborted ? 'Skill read cancelled' : 'Skill read authority or identity changed' }; }
|
|
802
|
+
}, assertCurrent() {
|
|
803
|
+
const failure = this.check(); if (failure) throw new SkillToolError(failure.code, failure.message); return true;
|
|
804
|
+
} };
|
|
805
|
+
try {
|
|
806
|
+
guard.assertCurrent();
|
|
807
|
+
const result = await raceAbort(signal, () => handlers[request.call.name](copy(request.call.arguments ?? {}), { guard }));
|
|
808
|
+
guard.assertCurrent(); return result;
|
|
809
|
+
} catch (error) { return { ok: false, error_type: signal.aborted ? 'REQUEST_CANCELLED' : error.code || 'SKILL_READ_FAILED', executed: false }; }
|
|
810
|
+
} };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function nativeSkillWorkflowToolDefinitions() { return copy(TOOL_DEFINITIONS.filter(definition => definition.name === 'execute_skills')); }
|