@particle-academy/prism-harness 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,50 @@ and writing the new messages as one operation. Two turns landing concurrently
71
71
  would otherwise both read position 4 and both write position 5, silently losing
72
72
  a message — the race the reference tracks as prism-harness#2.
73
73
 
74
+ ## Attachments
75
+
76
+ A turn can carry media alongside its prompt:
77
+
78
+ ```ts
79
+ import { Image } from '@particle-academy/prism';
80
+
81
+ await runtime.send(session, 'What is wrong with this layout?', undefined, undefined, [
82
+ Image.fromBase64(screenshot, 'image/png'),
83
+ ]);
84
+ ```
85
+
86
+ Pass `prism-ts` media objects or media already serialized. Attachments are stored
87
+ with the turn in the shape `UserMessage.fromObject()` rebuilds. Each one must be
88
+ an image, document, audio or video that carries its bytes, a provider file id or
89
+ document chunks. The same rules as the PHP reference and the Python port apply,
90
+ pinned across all three by prism-parity's `harness-turn-attachments` corpus.
91
+
92
+ **Refused, with a `HarnessError` whose `code` names the problem:**
93
+
94
+ | Code | When |
95
+ |---|---|
96
+ | `attachment_by_reference` | built from a URL or read from a file |
97
+ | `attachment_not_media` | anything other than those four media types |
98
+ | `attachment_empty` | no bytes, no file id and no chunks |
99
+ | `attachment_without_prompt` | attachments with an empty prompt |
100
+
101
+ A refusal happens before a run starts: no run, no events, nothing in the thread.
102
+
103
+ ## Provider options per mode
104
+
105
+ A mode can declare `provider_options`. They reach your model client unchanged,
106
+ as `request.providerOptions`, on every step of every run in that mode. Pass them
107
+ to your provider call (with `prism-ts`, `withProviderOptions()`):
108
+
109
+ ```ts
110
+ new ModeRegistry({
111
+ modes: { overseer: { system_prompt: '...', provider_options: { thinking: { enabled: true } } } },
112
+ });
113
+ ```
114
+
115
+ A value that is not a map is refused when the mode is resolved, with
116
+ `mode_malformed`.
117
+
74
118
  ## Agent task lists
75
119
 
76
120
  An agent given a goal has to keep working across many requests. It needs a list
@@ -0,0 +1,23 @@
1
+ import type { JsonObject } from './json.js';
2
+ /**
3
+ * Something a caller can attach to a turn.
4
+ *
5
+ * Either a `prism-ts` media object (anything with `toObject()`), or media
6
+ * already in its serialized form. This package has no dependency on `prism-ts`,
7
+ * so it asks the object the questions it needs rather than checking its class.
8
+ */
9
+ export type Attachment = JsonObject | MediaLike;
10
+ export interface MediaLike {
11
+ toObject(): JsonObject;
12
+ isUrl?(): boolean;
13
+ isFile?(): boolean;
14
+ }
15
+ /**
16
+ * Which media a turn will carry, decided before a run starts.
17
+ *
18
+ * The same rules and the same four codes as the PHP reference and the Python
19
+ * port, pinned across all three by prism-parity's `harness-turn-attachments`
20
+ * corpus. Returns the attachments serialized, in the form `prism-ts`'s
21
+ * `UserMessage.fromObject` rebuilds.
22
+ */
23
+ export declare function admitAttachments(prompt: string, attachments: readonly unknown[]): JsonObject[];
@@ -0,0 +1,73 @@
1
+ import { HarnessError } from './errors.js';
2
+ import { isJsonObject } from './json.js';
3
+ const MEDIA_KINDS = new Set(['image', 'audio', 'video', 'document']);
4
+ function isMediaLike(value) {
5
+ return typeof value === 'object' && value !== null && typeof value.toObject === 'function';
6
+ }
7
+ function nonEmptyString(value) {
8
+ return typeof value === 'string' && value !== '';
9
+ }
10
+ /**
11
+ * Bytes, decoded the way PHP's base64_decode() and Python's lenient decoder read
12
+ * them. A string that decodes to nothing (whitespace, or no base64 characters at
13
+ * all) carries nothing, however long it is.
14
+ */
15
+ function carriesBytes(value) {
16
+ return typeof value === 'string' && Buffer.from(value, 'base64').length > 0;
17
+ }
18
+ /**
19
+ * Which media a turn will carry, decided before a run starts.
20
+ *
21
+ * The same rules and the same four codes as the PHP reference and the Python
22
+ * port, pinned across all three by prism-parity's `harness-turn-attachments`
23
+ * corpus. Returns the attachments serialized, in the form `prism-ts`'s
24
+ * `UserMessage.fromObject` rebuilds.
25
+ */
26
+ export function admitAttachments(prompt, attachments) {
27
+ if (attachments.length === 0) {
28
+ return [];
29
+ }
30
+ if (prompt === '') {
31
+ throw HarnessError.attachmentWithoutPrompt();
32
+ }
33
+ return attachments.map((attachment, index) => {
34
+ let serialized;
35
+ if (isMediaLike(attachment)) {
36
+ // Asked of the OBJECT first. A prism-ts media built from a local path
37
+ // serializes as bytes with no path, so the serialized form alone could not
38
+ // tell that it came from a file.
39
+ if (attachment.isUrl?.() === true) {
40
+ throw HarnessError.attachmentByReference(index, 'a URL');
41
+ }
42
+ if (attachment.isFile?.() === true) {
43
+ throw HarnessError.attachmentByReference(index, 'a file path');
44
+ }
45
+ serialized = attachment.toObject();
46
+ }
47
+ else if (isJsonObject(attachment)) {
48
+ serialized = attachment;
49
+ }
50
+ else {
51
+ throw HarnessError.attachmentNotMedia(index, attachment === null ? 'null' : `a ${typeof attachment}`);
52
+ }
53
+ if (typeof serialized.kind !== 'string' || !MEDIA_KINDS.has(serialized.kind)) {
54
+ throw HarnessError.attachmentNotMedia(index, 'an object with no image, document, audio or video kind');
55
+ }
56
+ // ANY string, including the empty one. The reference's isUrl() asks whether a
57
+ // URL was set at all, and `fromUrl('')` is still media built from a URL.
58
+ if (typeof serialized.url === 'string') {
59
+ throw HarnessError.attachmentByReference(index, 'a URL');
60
+ }
61
+ if (typeof serialized.local_path === 'string' || typeof serialized.storage_path === 'string') {
62
+ throw HarnessError.attachmentByReference(index, 'a file path');
63
+ }
64
+ const chunks = serialized.chunks;
65
+ const carries = carriesBytes(serialized.base64) ||
66
+ nonEmptyString(serialized.file_id) ||
67
+ (serialized.kind === 'document' && Array.isArray(chunks) && chunks.length > 0);
68
+ if (!carries) {
69
+ throw HarnessError.attachmentEmpty(index);
70
+ }
71
+ return serialized;
72
+ });
73
+ }
package/dist/errors.d.ts CHANGED
@@ -60,7 +60,15 @@ export type HarnessErrorCode =
60
60
  * NOT in the set the Python port settled, and raised rather than dropped --
61
61
  * see `taskOutcomeInvalid` for why coercing instead is a security defect.
62
62
  */
63
- | 'task_outcome_invalid';
63
+ | 'task_outcome_invalid'
64
+ /** A turn attachment was built from a URL or a path. */
65
+ | 'attachment_by_reference'
66
+ /** A turn attachment is not an image, document, audio or video. */
67
+ | 'attachment_not_media'
68
+ /** A turn attachment carries no bytes, no file id and no chunks. */
69
+ | 'attachment_empty'
70
+ /** Attachments were offered with an empty prompt, which sends no user message. */
71
+ | 'attachment_without_prompt';
64
72
  export interface HarnessErrorOptions {
65
73
  cause?: unknown;
66
74
  }
@@ -196,4 +204,16 @@ export declare class HarnessError extends Error {
196
204
  */
197
205
  static completionToolWithoutAuthorizer(name: string): HarnessError;
198
206
  static noAgentRuntime(action: string): HarnessError;
207
+ /**
208
+ * A turn attachment built from a URL or a path.
209
+ *
210
+ * A URL from request input is somebody else's choice of address, and a path's
211
+ * contents would go to a third-party model. The same four attachment codes are
212
+ * raised by the PHP reference and the Python port.
213
+ */
214
+ static attachmentByReference(index: number, what: string): HarnessError;
215
+ static attachmentNotMedia(index: number, type: string): HarnessError;
216
+ static attachmentEmpty(index: number): HarnessError;
217
+ /** An empty prompt resumes a paused run and sends no user message, so attachments would vanish. */
218
+ static attachmentWithoutPrompt(): HarnessError;
199
219
  }
package/dist/errors.js CHANGED
@@ -198,4 +198,26 @@ export class HarnessError extends Error {
198
198
  static noAgentRuntime(action) {
199
199
  return new HarnessError('no_agent_runtime', `This session cannot ${action}: it was built without an agent runtime.`);
200
200
  }
201
+ /**
202
+ * A turn attachment built from a URL or a path.
203
+ *
204
+ * A URL from request input is somebody else's choice of address, and a path's
205
+ * contents would go to a third-party model. The same four attachment codes are
206
+ * raised by the PHP reference and the Python port.
207
+ */
208
+ static attachmentByReference(index, what) {
209
+ return new HarnessError('attachment_by_reference', `Attachment [${index}] was built from ${what}. A turn sends media as its bytes or as a provider file id, ` +
210
+ 'never as a URL or path for someone else to resolve. Read the content yourself and attach the bytes.');
211
+ }
212
+ static attachmentNotMedia(index, type) {
213
+ return new HarnessError('attachment_not_media', `Attachment [${index}] is ${type}, not an image, document, audio or video. A turn's text belongs in the prompt.`);
214
+ }
215
+ static attachmentEmpty(index) {
216
+ return new HarnessError('attachment_empty', `Attachment [${index}] carries nothing to send: no bytes, no file id and no chunks.`);
217
+ }
218
+ /** An empty prompt resumes a paused run and sends no user message, so attachments would vanish. */
219
+ static attachmentWithoutPrompt() {
220
+ return new HarnessError('attachment_without_prompt', 'Attachments need a prompt to travel with. An empty prompt resumes a paused run and sends no user ' +
221
+ 'message, so these attachments would be dropped without a word.');
222
+ }
201
223
  }
package/dist/index.d.ts CHANGED
@@ -34,3 +34,5 @@ export type { CompletionToolOptions } from './task-tools.js';
34
34
  export { SkillRegistry } from './skills.js';
35
35
  export { SubagentRunner } from './subagent-runner.js';
36
36
  export type { SubagentOutcome, SubagentResult } from './subagent-runner.js';
37
+ export { admitAttachments } from './attachments.js';
38
+ export type { Attachment, MediaLike } from './attachments.js';
package/dist/index.js CHANGED
@@ -18,3 +18,4 @@ export { StoreTaskSource, StoredAgentTask } from './store-task-source.js';
18
18
  export { agentCompletionTool } from './task-tools.js';
19
19
  export { SkillRegistry } from './skills.js';
20
20
  export { SubagentRunner } from './subagent-runner.js';
21
+ export { admitAttachments } from './attachments.js';
package/dist/modes.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { JsonObject } from './json.js';
1
2
  import type { Subagent } from './subagents.js';
2
3
  export interface ModeConfig {
3
4
  system_prompt?: string;
@@ -6,6 +7,8 @@ export interface ModeConfig {
6
7
  max_steps?: number;
7
8
  subagents?: Record<string, unknown>;
8
9
  requires_approval?: readonly string[];
10
+ /** Passed unchanged to the model client on every run in this mode. Must be a map. */
11
+ provider_options?: Record<string, unknown>;
9
12
  }
10
13
  export interface ModeRegistryConfig {
11
14
  default?: string;
@@ -28,11 +31,15 @@ export declare class AgentMode {
28
31
  readonly subagents: Readonly<Record<string, Subagent>>;
29
32
  /** Tools that must not run until a human says so. */
30
33
  readonly requiresApproval: readonly string[];
34
+ /** Options handed to the provider on every run in this mode, unchanged. */
35
+ readonly providerOptions: Readonly<JsonObject>;
31
36
  constructor(name: string, systemPrompt: string, tools: readonly string[], skills: readonly string[], maxSteps: number,
32
37
  /** Nested agents this mode may call, by name. */
33
38
  subagents?: Readonly<Record<string, Subagent>>,
34
39
  /** Tools that must not run until a human says so. */
35
- requiresApproval?: readonly string[]);
40
+ requiresApproval?: readonly string[],
41
+ /** Options handed to the provider on every run in this mode, unchanged. */
42
+ providerOptions?: Readonly<JsonObject>);
36
43
  /**
37
44
  * Whether a named tool needs a human before it runs IN THIS MODE.
38
45
  *
package/dist/modes.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { HarnessError } from './errors.js';
2
+ import { isJsonObject } from './json.js';
2
3
  import { subagentFromConfig } from './subagents.js';
3
4
  /**
4
5
  * What a mode lets a run do.
@@ -15,11 +16,14 @@ export class AgentMode {
15
16
  maxSteps;
16
17
  subagents;
17
18
  requiresApproval;
19
+ providerOptions;
18
20
  constructor(name, systemPrompt, tools, skills, maxSteps,
19
21
  /** Nested agents this mode may call, by name. */
20
22
  subagents = {},
21
23
  /** Tools that must not run until a human says so. */
22
- requiresApproval = []) {
24
+ requiresApproval = [],
25
+ /** Options handed to the provider on every run in this mode, unchanged. */
26
+ providerOptions = {}) {
23
27
  this.name = name;
24
28
  this.systemPrompt = systemPrompt;
25
29
  this.tools = tools;
@@ -27,6 +31,7 @@ export class AgentMode {
27
31
  this.maxSteps = maxSteps;
28
32
  this.subagents = subagents;
29
33
  this.requiresApproval = requiresApproval;
34
+ this.providerOptions = providerOptions;
30
35
  }
31
36
  /**
32
37
  * Whether a named tool needs a human before it runs IN THIS MODE.
@@ -75,7 +80,7 @@ export class ModeRegistry {
75
80
  if (typeof prompt !== 'string' || !Number.isInteger(maxSteps) || maxSteps < 1) {
76
81
  throw HarnessError.modeMalformed(wanted, 'system_prompt must be a string and max_steps a positive integer');
77
82
  }
78
- return new AgentMode(wanted, prompt, strings(mode.tools), strings(mode.skills), maxSteps, this.#subagentsFor(wanted, mode), strings(mode.requires_approval));
83
+ return new AgentMode(wanted, prompt, strings(mode.tools), strings(mode.skills), maxSteps, this.#subagentsFor(wanted, mode), strings(mode.requires_approval), providerOptionsFor(wanted, mode.provider_options));
79
84
  }
80
85
  /**
81
86
  * The nested agents a mode may call.
@@ -107,3 +112,18 @@ export class ModeRegistry {
107
112
  function strings(value) {
108
113
  return (value ?? []).filter((entry) => typeof entry === 'string');
109
114
  }
115
+ /**
116
+ * A mode's provider options, refused rather than ignored when they are not a map.
117
+ *
118
+ * A list or a scalar would reach the provider as nothing, and the mode would run
119
+ * without the option its author believes is on.
120
+ */
121
+ function providerOptionsFor(name, declared) {
122
+ if (declared === undefined) {
123
+ return {};
124
+ }
125
+ if (!isJsonObject(declared)) {
126
+ throw HarnessError.modeMalformed(name, 'provider_options must be a map of option names to values');
127
+ }
128
+ return declared;
129
+ }
package/dist/runtime.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface LlmRequest {
22
22
  tools: readonly HarnessTool[];
23
23
  provider: string;
24
24
  model: string;
25
+ /**
26
+ * The mode's `provider_options`, unchanged. A client passes them to its
27
+ * provider call (for prism-ts, `withProviderOptions()`); the harness does not
28
+ * interpret them.
29
+ */
30
+ providerOptions: Readonly<JsonObject>;
25
31
  }
26
32
  export interface LlmToolCall {
27
33
  id: string;
@@ -90,7 +96,7 @@ export declare class AgentRuntime {
90
96
  * the decision, and everything before them. A new prompt there would be a
91
97
  * second instruction competing with the one the tool call came from.
92
98
  */
93
- send(session: Session, prompt: string, toolNames?: readonly string[], context?: RunContext): Promise<AgentResponse>;
99
+ send(session: Session, prompt: string, toolNames?: readonly string[], context?: RunContext, additionalContent?: readonly unknown[]): Promise<AgentResponse>;
94
100
  }
95
101
  /**
96
102
  * Answer a pending approval, durably.
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { admitAttachments } from './attachments.js';
2
3
  import { HarnessError } from './errors.js';
3
4
  import { RunBudget, RunContext } from './subagents.js';
4
5
  /**
@@ -40,7 +41,10 @@ export class AgentRuntime {
40
41
  * the decision, and everything before them. A new prompt there would be a
41
42
  * second instruction competing with the one the tool call came from.
42
43
  */
43
- async send(session, prompt, toolNames, context) {
44
+ async send(session, prompt, toolNames, context, additionalContent = []) {
45
+ // Refused before a run exists: a bad attachment is a mistake in the call,
46
+ // and it should not cost a run, events or budget.
47
+ const attachments = admitAttachments(prompt, additionalContent);
44
48
  const mode = this.#modes.resolve(await session.mode());
45
49
  const provider = (await session.provider()) ?? 'unknown';
46
50
  const model = (await session.model()) ?? 'unknown';
@@ -64,7 +68,13 @@ export class AgentRuntime {
64
68
  at: new Date().toISOString(),
65
69
  });
66
70
  if (prompt !== '') {
67
- await thread.record([{ type: 'user', content: prompt }], runId);
71
+ // With attachments, the shape prism-ts's UserMessage.toObject() writes: the
72
+ // media parts, then the turn's own text as a trailing text part, which
73
+ // fromObject() strips back off. Without them, unchanged.
74
+ const turn = attachments.length === 0
75
+ ? { type: 'user', content: prompt }
76
+ : { type: 'user', content: prompt, additional_content: [...attachments, { text: prompt }], additional_attributes: {} };
77
+ await thread.record([turn], runId);
68
78
  }
69
79
  try {
70
80
  return await this.#loop(session, mode, run, runId, provider, model, toolNames);
@@ -105,6 +115,7 @@ export class AgentRuntime {
105
115
  tools: offered,
106
116
  provider,
107
117
  model,
118
+ providerOptions: mode.providerOptions,
108
119
  });
109
120
  run.ledger.recordSteps(1);
110
121
  run.ledger.recordCost(response.costUsd ?? null);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@particle-academy/prism-harness",
3
- "version": "0.1.0",
4
- "description": "Durable agent sessions for TypeScript \u2014 threads, session state and store drivers.",
3
+ "version": "0.2.0",
4
+ "description": "Durable agent sessions for TypeScript — threads, session state and store drivers.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "engines": {