@springbrand/agent-runtime 0.2.0-alpha.40 → 0.2.0-alpha.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.40",
3
+ "version": "0.2.0-alpha.42",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -0,0 +1,215 @@
1
+ import {
2
+ parseSkillMarkdown,
3
+ type SkillContent,
4
+ type SkillDescriptor,
5
+ type SkillResource,
6
+ type SkillResourceDescriptor,
7
+ type SkillSource,
8
+ } from "agents/skills";
9
+
10
+ type ListedObject = Pick<R2Object, "key" | "size">;
11
+
12
+ interface IndexedSkill {
13
+ readonly descriptor: SkillDescriptor;
14
+ readonly content: SkillContent;
15
+ readonly directory: string;
16
+ readonly resources: ReadonlyMap<string, SkillResourceDescriptor>;
17
+ }
18
+
19
+ const TEXT_EXTENSIONS = new Set([
20
+ ".bash",
21
+ ".css",
22
+ ".csv",
23
+ ".html",
24
+ ".js",
25
+ ".json",
26
+ ".jsx",
27
+ ".md",
28
+ ".mjs",
29
+ ".py",
30
+ ".sh",
31
+ ".svg",
32
+ ".ts",
33
+ ".tsx",
34
+ ".txt",
35
+ ".xml",
36
+ ".yaml",
37
+ ".yml",
38
+ ]);
39
+
40
+ function trimSlashes(value: string): string {
41
+ return value.replace(/^\/+/, "").replace(/\/+$/, "");
42
+ }
43
+
44
+ function resourceKind(path: string): SkillResourceDescriptor["kind"] {
45
+ if (path.startsWith("references/")) return "reference";
46
+ if (path.startsWith("scripts/")) return "script";
47
+ if (path.startsWith("assets/")) return "asset";
48
+ return "file";
49
+ }
50
+
51
+ function resourceEncoding(path: string): "text" | "base64" {
52
+ const file = path.split("/").at(-1) ?? path;
53
+ const dot = file.lastIndexOf(".");
54
+ return TEXT_EXTENSIONS.has(dot < 0 ? "" : file.slice(dot).toLowerCase())
55
+ ? "text"
56
+ : "base64";
57
+ }
58
+
59
+ function normalizedResourcePath(path: string): boolean {
60
+ return !path.startsWith("/") &&
61
+ !path.includes("\0") &&
62
+ path.split("/").every((part) => part !== "" && part !== "." && part !== "..");
63
+ }
64
+
65
+ function base64Encode(buffer: ArrayBuffer): string {
66
+ let binary = "";
67
+ for (const byte of new Uint8Array(buffer)) {
68
+ binary += String.fromCharCode(byte);
69
+ }
70
+ return btoa(binary);
71
+ }
72
+
73
+ async function readR2<T>(operation: () => Promise<T>): Promise<T> {
74
+ try {
75
+ return await operation();
76
+ } catch {
77
+ return operation();
78
+ }
79
+ }
80
+
81
+ async function listAllObjects(
82
+ bucket: R2Bucket,
83
+ prefix: string,
84
+ ): Promise<ListedObject[]> {
85
+ const objects: ListedObject[] = [];
86
+ let cursor: string | undefined;
87
+ do {
88
+ const page = await readR2(() => bucket.list({
89
+ prefix,
90
+ ...(cursor ? { cursor } : {}),
91
+ }));
92
+ objects.push(...page.objects);
93
+ cursor = page.truncated ? page.cursor : undefined;
94
+ } while (cursor);
95
+ return objects;
96
+ }
97
+
98
+ async function readText(bucket: R2Bucket, key: string): Promise<string> {
99
+ return readR2(async () => {
100
+ const object = await bucket.get(key);
101
+ if (!object) throw new Error(`Skill content not found: ${key}`);
102
+ return object.text();
103
+ });
104
+ }
105
+
106
+ async function readResource(
107
+ bucket: R2Bucket,
108
+ key: string,
109
+ descriptor: SkillResourceDescriptor,
110
+ ): Promise<SkillResource> {
111
+ return readR2(async () => {
112
+ const object = await bucket.get(key);
113
+ if (!object) throw new Error(`Skill resource not found: ${key}`);
114
+ const encoding = descriptor.encoding ?? resourceEncoding(descriptor.path);
115
+ return {
116
+ ...descriptor,
117
+ encoding,
118
+ ...(object.httpMetadata?.contentType
119
+ ? { mimeType: object.httpMetadata.contentType }
120
+ : {}),
121
+ content: encoding === "text"
122
+ ? await object.text()
123
+ : base64Encode(await object.arrayBuffer()),
124
+ };
125
+ });
126
+ }
127
+
128
+ /** Create the immutable, single-Skill R2 Source used by SpringBrand runtimes. */
129
+ export function createR2SkillSource(
130
+ bucket: R2Bucket,
131
+ resourceId: string,
132
+ contentHash: string,
133
+ contentRef: string,
134
+ ): SkillSource {
135
+ const prefix = `${trimSlashes(contentRef)}/`;
136
+ const id = `resource-${resourceId}-${contentHash}`;
137
+ let indexPromise: Promise<IndexedSkill> | undefined;
138
+
139
+ const loadIndex = async (): Promise<IndexedSkill> => {
140
+ const objects = await listAllObjects(bucket, prefix);
141
+ const skillObjects = objects.filter(({ key }) => key.endsWith("/SKILL.md"));
142
+ if (skillObjects.length !== 1) {
143
+ throw new Error(`Skill content requires exactly one SKILL.md: ${contentRef}`);
144
+ }
145
+ const skillKey = skillObjects[0]!.key;
146
+ const directory = skillKey.slice(prefix.length, -"/SKILL.md".length);
147
+ if (!directory || directory.includes("/")) {
148
+ throw new Error(`Skill content has an invalid directory: ${contentRef}`);
149
+ }
150
+ const parsed = parseSkillMarkdown(await readText(bucket, skillKey));
151
+ if (!parsed) throw new Error(`Skill content has invalid frontmatter: ${contentRef}`);
152
+
153
+ const resourcePrefix = `${prefix}${directory}/`;
154
+ const resources = objects.flatMap(({ key, size }) => {
155
+ if (key === skillKey || !key.startsWith(resourcePrefix)) return [];
156
+ const path = key.slice(resourcePrefix.length);
157
+ return normalizedResourcePath(path)
158
+ ? [{ path, kind: resourceKind(path), encoding: resourceEncoding(path), size }]
159
+ : [];
160
+ });
161
+ const descriptor: SkillDescriptor = {
162
+ name: parsed.name,
163
+ description: parsed.description,
164
+ compatibility: parsed.compatibility,
165
+ license: parsed.license,
166
+ allowedTools: parsed.allowedTools,
167
+ metadata: parsed.metadata,
168
+ sourceId: id,
169
+ };
170
+ return {
171
+ descriptor,
172
+ directory,
173
+ resources: new Map(resources.map((resource) => [resource.path, resource])),
174
+ content: {
175
+ ...descriptor,
176
+ body: parsed.body,
177
+ resources,
178
+ },
179
+ };
180
+ };
181
+
182
+ const index = (): Promise<IndexedSkill> => {
183
+ if (!indexPromise) {
184
+ indexPromise = loadIndex().catch((error) => {
185
+ indexPromise = undefined;
186
+ throw error;
187
+ });
188
+ }
189
+ return indexPromise;
190
+ };
191
+
192
+ return {
193
+ id,
194
+ fingerprint: id,
195
+ async list() {
196
+ return [{ ...(await index()).descriptor }];
197
+ },
198
+ async load(name) {
199
+ const indexed = await index();
200
+ return indexed.descriptor.name === name ? { ...indexed.content } : null;
201
+ },
202
+ async readResource(name, path) {
203
+ if (!normalizedResourcePath(path)) return null;
204
+ const indexed = await index();
205
+ if (indexed.descriptor.name !== name) return null;
206
+ const descriptor = indexed.resources.get(path);
207
+ if (!descriptor) return null;
208
+ return readResource(
209
+ bucket,
210
+ `${prefix}${indexed.directory}/${path}`,
211
+ descriptor,
212
+ );
213
+ },
214
+ };
215
+ }
@@ -1,8 +1,8 @@
1
- import { r2, type SkillSource } from "agents/skills";
2
1
  import type { RuntimeDegradation } from "../../../kernel/degradation";
3
2
  import type { RuntimeExtensionConfig } from "../../../kernel/extensions";
4
3
  import { withRuntimeLoadTimeout } from "../../../kernel/runtime-load";
5
4
  import type { RuntimeExtensionContribution } from "../../../runtime-definition";
5
+ export { createR2SkillSource } from "./r2-skill-source";
6
6
 
7
7
  export interface CloudflareRuntimeExtensionSource {
8
8
  readonly name: string;
@@ -11,29 +11,12 @@ export interface CloudflareRuntimeExtensionSource {
11
11
  readonly sourceHash: string;
12
12
  }
13
13
 
14
- function trimSlashes(value: string): string {
15
- return value.replace(/^\/+/, "").replace(/\/+$/, "");
16
- }
17
-
18
14
  function hex(bytes: ArrayBuffer): string {
19
15
  return [...new Uint8Array(bytes)]
20
16
  .map((byte) => byte.toString(16).padStart(2, "0"))
21
17
  .join("");
22
18
  }
23
19
 
24
- export function createR2SkillSource(
25
- bucket: R2Bucket,
26
- resourceId: string,
27
- contentHash: string,
28
- contentRef: string,
29
- ): SkillSource {
30
- return r2(bucket, {
31
- prefix: `${trimSlashes(contentRef)}/`,
32
- id: `resource-${resourceId}-${contentHash}`,
33
- fingerprint: "metadata",
34
- });
35
- }
36
-
37
20
  export async function readVerifiedR2Text(
38
21
  bucket: R2Bucket,
39
22
  sourceRef: string,
package/src/lib/prompt.ts CHANGED
@@ -14,7 +14,7 @@ export const PERSONA =
14
14
  "You can recall user-managed cold memory across sessions and keep working memory within the current Session, " +
15
15
  "manage files in your workspace, and use execute Code Mode for network requests and tool composition.";
16
16
 
17
- // Explains how to reach capabilities instead of listing unsupported substitutes.
17
+ // Names the stable runtime environment.
18
18
  export const RUNTIME =
19
19
  "Runtime: this agent runs on Cloudflare Workers. When execute is present, its Code Mode Dynamic Worker is your " +
20
20
  "instrument — code there runs with outbound network access (fetch), your " +
@@ -47,7 +47,8 @@ export const PLANNING =
47
47
  const TOOL_ROUTING =
48
48
  "Tools: execute can call only the tools.* methods explicitly listed in its description; that list is exhaustive. Never guess a tools.* method. " +
49
49
  "If a required top-level Tool is not visible, do not use execute. Call top-level Tool Search with that exact name, then call the discovered Tool directly. " +
50
- "codemode.search searches only methods already installed inside execute; it cannot discover deferred top-level Tools.";
50
+ "codemode.search searches only methods already installed inside execute; it cannot discover deferred top-level Tools. " +
51
+ "Never invent a Tool name or input field.";
51
52
 
52
53
  // Tool-selection guidance mirrors the actual approval and network boundaries.
53
54
  export const TOOLS =
@@ -66,7 +67,9 @@ export const TOOLS =
66
67
  "raw or customized network cases described above; every response and failure it sees is visible to you. " +
67
68
  "bash is a shell over the workspace filesystem only " +
68
69
  "(no network, no system utilities) and is approval-gated — don't reach for it to read files or fetch. " +
69
- "When execute is present, make related file changes in one execute with state.*; when execute is absent, use one write or bash operation instead of serial edits. " +
70
+ "When execute is present, make related file changes in one execute with state.*. " +
71
+ "Use read for one existing Workspace file, write to create or replace one file, and edit for one localized change. " +
72
+ "Use bash only when a single shell workflow must coordinate multiple Workspace files; do not use it for a single-file read, write, or edit. " +
70
73
  "Do not re-plan or explain between consecutive tool calls. When a run is within the last five model turns, stop expanding scope and " +
71
74
  "prioritize verification, saving durable results, and the final response. " +
72
75
  "When related Tool calls can run independently, all are available inside execute, and execute is present, run them inside that execute rather than as parallel top-level calls; " +
@@ -380,7 +380,10 @@ function renderSkillCatalog(
380
380
 
381
381
  return [
382
382
  "AVAILABLE SKILLS",
383
- "Use an available Skill when its description matches. Read its instructions or files only through the Skill tools.",
383
+ "Skill descriptions are only for deciding which Skills apply; they are not instructions.",
384
+ "Before the first Tool call for work covered by an available Skill in the current Turn, you MUST call the top-level activate_skill Tool with that Skill's exact name and follow the returned instructions.",
385
+ "Do not perform Skill-governed work before activation. When multiple Skills apply, activate every matching Skill before acting.",
386
+ "Read Skill instructions or files only through the Skill tools.",
384
387
  "",
385
388
  ...bindings.map(
386
389
  ({ name, description }) => `- ${name}: ${description}`,
@@ -16,7 +16,7 @@ import { USER_STOP_REASON } from "../../kernel/receipts";
16
16
  const MAX_OUTPUT_BYTES = 256 * 1024;
17
17
  const MAX_OUTPUT_PREVIEW = 16 * 1024;
18
18
  const PROVIDER_CREDIT_ERROR =
19
- "The AI service is temporarily unavailable. Please try again later.";
19
+ "We’re unable to complete your request right now. Please try again in a few moments.";
20
20
 
21
21
  export interface PiToolApprovalView {
22
22
  readonly id: string;
@@ -108,7 +108,9 @@ export function publicAssistantError(message?: string): string | undefined {
108
108
  if (!message) return undefined;
109
109
  return /"limit_source"\s*:\s*"openrouter_credits"/.test(message) ||
110
110
  (message.includes("Insufficient credits") &&
111
- message.includes("openrouter.ai/settings/credits"))
111
+ message.includes("openrouter.ai/settings/credits")) ||
112
+ (message.includes("can only afford") &&
113
+ /openrouter\.ai\/workspaces\/[^/\s]+\/keys\//.test(message))
112
114
  ? PROVIDER_CREDIT_ERROR
113
115
  : message;
114
116
  }
@@ -183,13 +183,31 @@ export function normalizeUpdatePlanArguments(
183
183
  return input as UpdatePlanArguments;
184
184
  }
185
185
  const value = input as Record<string, unknown>;
186
- if (!Array.isArray(value.steps)) return input as UpdatePlanArguments;
186
+ let steps = value.steps;
187
+ if (typeof steps === "string") {
188
+ try {
189
+ steps = JSON.parse(steps);
190
+ } catch {
191
+ return input as UpdatePlanArguments;
192
+ }
193
+ }
194
+ if (!Array.isArray(steps)) return input as UpdatePlanArguments;
187
195
  return {
188
196
  ...value,
189
- steps: value.steps.map((step) => {
197
+ steps: steps.map((step) => {
190
198
  if (step === null || typeof step !== "object") return step;
191
199
  const s = step as Record<string, unknown>;
192
- return { ...s, status: normalizeStepStatus(s.status) };
200
+ const { step: alias, ...rest } = s;
201
+ const text = typeof rest.text === "string"
202
+ ? rest.text
203
+ : typeof alias === "string"
204
+ ? alias
205
+ : undefined;
206
+ return {
207
+ ...rest,
208
+ ...(text === undefined ? {} : { text }),
209
+ status: normalizeStepStatus(s.status),
210
+ };
193
211
  }),
194
212
  } as UpdatePlanArguments;
195
213
  }
@@ -198,12 +216,17 @@ const setContextParameters = Type.Object({
198
216
  label: Type.Union([
199
217
  Type.Literal("memory"),
200
218
  Type.Literal("preferences"),
201
- ]),
202
- content: Type.String(),
219
+ ], {
220
+ description:
221
+ "Context block to update: memory for durable facts and active context, preferences for tone and workflow choices.",
222
+ }),
223
+ content: Type.String({ description: "Text to store in the selected context block." }),
203
224
  action: Type.Optional(Type.Union([
204
225
  Type.Literal("replace"),
205
226
  Type.Literal("append"),
206
- ])),
227
+ ], {
228
+ description: 'Whether to replace the block or append to it. Defaults to "replace".',
229
+ })),
207
230
  });
208
231
 
209
232
  function result<T>(details: T): AgentToolResult<T> {
@@ -46,6 +46,12 @@ const SKILL_RESOURCE_BUDGET_BYTES = 8 * 1024 * 1024;
46
46
 
47
47
  type LoadedSkill = NonNullable<Awaited<ReturnType<SkillSource["load"]>>>;
48
48
 
49
+ const SKILL_RESOURCE_READ_GUIDANCE =
50
+ "Bundled Skill resources are not Workspace files. " +
51
+ "Use read_skill_resource for text instead of read, bash, or find on /skills.";
52
+ const SKILL_RESOURCE_MATERIALIZE_GUIDANCE =
53
+ "Use materialize_skill_resource for Workspace assets when that Tool is available.";
54
+
49
55
  /**
50
56
  * 按体积裁掉超预算的 Skill 资源,并把裁掉的事实写回 Skill 正文。
51
57
  *
@@ -121,7 +127,21 @@ function catalogSkillSource(binding: PiSkillBinding): SkillSource {
121
127
  }],
122
128
  load: async (name) => {
123
129
  const skill = await source.load(name);
124
- return skill && budgetSkillResources(skill);
130
+ if (!skill) return null;
131
+ const loaded = budgetSkillResources(skill);
132
+ const hasOversizedResource = skill.resources?.some((resource) =>
133
+ typeof resource.size === "number" &&
134
+ resource.size > SKILL_RESOURCE_BUDGET_BYTES
135
+ );
136
+ const guidance = hasOversizedResource
137
+ ? SKILL_RESOURCE_READ_GUIDANCE
138
+ : `${SKILL_RESOURCE_READ_GUIDANCE} ${SKILL_RESOURCE_MATERIALIZE_GUIDANCE}`;
139
+ return loaded.resources?.length
140
+ ? {
141
+ ...loaded,
142
+ body: `${loaded.body}\n\n${guidance}`,
143
+ }
144
+ : loaded;
125
145
  },
126
146
  ...(source.readResource
127
147
  ? { readResource: (name: string, path: string) =>
@@ -175,9 +195,38 @@ const SKILL_TOOL_LABELS: Readonly<Record<string, string>> = {
175
195
  materialize_skill_resource: "Materialize Skill resource",
176
196
  };
177
197
 
198
+ const SKILL_TOOL_PARAMETER_DESCRIPTIONS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
199
+ activate_skill: {
200
+ name: "Exact name of the available Skill to activate.",
201
+ },
202
+ read_skill_resource: {
203
+ name: "Name of the activated Skill. Omit only when path starts with the Skill name.",
204
+ path: "Bundled resource path listed by activate_skill.",
205
+ },
206
+ run_skill_script: {
207
+ name: "Name of the activated Skill that supplies the script.",
208
+ path: "Bundled script path listed by activate_skill.",
209
+ input: "JSON input expected by the Skill script. Defaults to an empty object.",
210
+ },
211
+ };
212
+
178
213
  const SKILL_ENTRY_READ_GUIDANCE =
179
214
  "SKILL.md contains the Skill instructions; use activate_skill instead.";
180
215
 
216
+ function normalizeRunSkillScriptArguments(input: unknown): unknown {
217
+ if (input === null || typeof input !== "object") return input;
218
+ const value = input as Record<string, unknown>;
219
+ if (typeof value.input !== "string") return input;
220
+ try {
221
+ const decoded = JSON.parse(value.input);
222
+ return decoded !== null && typeof decoded === "object"
223
+ ? { ...value, input: decoded }
224
+ : input;
225
+ } catch {
226
+ return input;
227
+ }
228
+ }
229
+
181
230
  function resourceBytes(resource: SkillResource): Uint8Array {
182
231
  if ((resource.encoding ?? "text") === "text") {
183
232
  return new TextEncoder().encode(resource.content);
@@ -270,6 +319,17 @@ export async function skillPiToolCandidates(
270
319
  }
271
320
  : {}),
272
321
  });
322
+ const properties = (adapted.parameters as { properties?: unknown }).properties;
323
+ if (properties && typeof properties === "object" && !Array.isArray(properties)) {
324
+ for (const [parameter, description] of Object.entries(
325
+ SKILL_TOOL_PARAMETER_DESCRIPTIONS[name] ?? {},
326
+ )) {
327
+ const schema = (properties as Record<string, unknown>)[parameter];
328
+ if (schema && typeof schema === "object" && !Array.isArray(schema)) {
329
+ (schema as Record<string, unknown>).description ??= description;
330
+ }
331
+ }
332
+ }
273
333
  if (name === "read_skill_resource") {
274
334
  const execute = adapted.execute;
275
335
  adapted.execute = (toolCallId, input, signal) => {
@@ -291,6 +351,9 @@ export async function skillPiToolCandidates(
291
351
  return execute(toolCallId, input, signal);
292
352
  };
293
353
  }
354
+ if (name === "run_skill_script") {
355
+ adapted.prepareArguments = normalizeRunSkillScriptArguments;
356
+ }
294
357
  return {
295
358
  owner: "runtime-skill",
296
359
  requiredExecutionLevel:
@@ -70,9 +70,16 @@ const workspaceReadParameters = Type.Object({
70
70
  })),
71
71
  });
72
72
  const workspaceEditParameters = Type.Object({
73
- path: Type.String({ minLength: 1, maxLength: 4_096 }),
74
- old_string: Type.String(),
75
- new_string: Type.String(),
73
+ path: Type.String({
74
+ minLength: 1,
75
+ maxLength: 4_096,
76
+ description: "Absolute Workspace path of the file to edit.",
77
+ }),
78
+ old_string: Type.String({
79
+ description:
80
+ "Exact existing text to replace. Include enough surrounding context to match one location.",
81
+ }),
82
+ new_string: Type.String({ description: "Replacement text." }),
76
83
  });
77
84
 
78
85
  /**
@@ -430,24 +437,47 @@ export function workspacePiToolCandidates(
430
437
  // #region Sandbox tools
431
438
 
432
439
  const sandboxCommandParameters = {
433
- command: Type.String({ minLength: 1, maxLength: 32_768 }),
434
- cwd: Type.Optional(Type.String({ maxLength: 4_096 })),
435
- stdin: Type.Optional(Type.String({ maxLength: 65_536 })),
440
+ command: Type.String({
441
+ minLength: 1,
442
+ maxLength: 32_768,
443
+ description: "Shell command to run in the isolated Linux Sandbox.",
444
+ }),
445
+ cwd: Type.Optional(Type.String({
446
+ maxLength: 4_096,
447
+ description: "Sandbox working directory. Omit to use the Sandbox default.",
448
+ })),
449
+ stdin: Type.Optional(Type.String({
450
+ maxLength: 65_536,
451
+ description: "Optional text to pass to the command on standard input.",
452
+ })),
436
453
  };
437
454
  const sandboxExecParameters = Type.Object({
438
455
  ...sandboxCommandParameters,
439
456
  timeoutMs: Type.Optional(
440
- Type.Integer({ minimum: 1, maximum: 60_000 }),
457
+ Type.Integer({
458
+ minimum: 1,
459
+ maximum: 60_000,
460
+ description: "Maximum command runtime in milliseconds, from 1 to 60000.",
461
+ }),
441
462
  ),
442
463
  });
443
464
  const sandboxStartParameters = Type.Object(sandboxCommandParameters);
444
465
  const sandboxProcessParameters = Type.Object({
445
- id: Type.String({ minLength: 1, maxLength: 128 }),
466
+ id: Type.String({
467
+ minLength: 1,
468
+ maxLength: 128,
469
+ description: "Process ID returned by sandbox_start_process.",
470
+ }),
446
471
  });
447
472
  const sandboxPublishParameters = Type.Object({
448
- paths: Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), {
473
+ paths: Type.Array(Type.String({
474
+ minLength: 1,
475
+ maxLength: 4_096,
476
+ description: "Sandbox file path to publish.",
477
+ }), {
449
478
  minItems: 1,
450
479
  maxItems: 100,
480
+ description: "Files to copy from the temporary Sandbox into the persistent Workspace.",
451
481
  }),
452
482
  });
453
483