@particle-academy/prism-harness 0.7.0 → 0.9.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/dist/harness.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Session } from './session.js';
2
2
  import { MemorySessionStore } from './stores/memory-session-store.js';
3
3
  import { SessionStoreManager } from './stores/store-manager.js';
4
+ import { ParticipantAdapters, ParticipantRef, SessionAddress, validateAgent } from './address.js';
5
+ import { addressLimit, indexedStore, StoreAddressIndex } from './address-index.js';
6
+ import { StoreTaskSource } from './store-task-source.js';
7
+ import { Thread } from './thread.js';
4
8
  /**
5
9
  * The entry point: `harness.for(participant).session('support')`.
6
10
  *
@@ -24,8 +28,25 @@ import { SessionStoreManager } from './stores/store-manager.js';
24
28
  export class PrismHarness {
25
29
  #stores;
26
30
  #ttlSeconds;
31
+ #participants;
32
+ #maxAddresses;
33
+ #runtime;
34
+ #agents;
35
+ #defaultScope;
36
+ #defaultTaskList;
27
37
  constructor(options = {}) {
38
+ this.#runtime = options.runtime;
39
+ this.#defaultScope = options.defaultScope ?? 'default';
40
+ this.#defaultTaskList = options.defaultTaskList ?? 'default';
41
+ validateHarnessConfig({ defaultScope: this.#defaultScope, defaultTaskList: this.#defaultTaskList });
42
+ this.#agents = Object.fromEntries(Object.entries(options.agents ?? {}).map(([agent, config]) => {
43
+ validateAgent(agent);
44
+ validateHarnessConfig(config);
45
+ return [agent, { ...config }];
46
+ }));
28
47
  this.#ttlSeconds = options.ttlSeconds ?? null;
48
+ this.#participants = options.participantAdapters ?? new ParticipantAdapters();
49
+ this.#maxAddresses = addressLimit(options.maxAddressesPerParticipant ?? null);
29
50
  this.#stores = new SessionStoreManager({
30
51
  stores: options.stores,
31
52
  drivers: options.drivers ?? { default: () => new MemorySessionStore() },
@@ -33,7 +54,36 @@ export class PrismHarness {
33
54
  }
34
55
  /** Bind to a participant. Returns a builder, because the scope comes next. */
35
56
  for(participant) {
36
- return new PendingSession(participant, this.#stores, this.#ttlSeconds);
57
+ return this.pending(participant, null);
58
+ }
59
+ forAgent(agent) {
60
+ validateAgent(agent);
61
+ return new AgentHarness(this, agent);
62
+ }
63
+ effectiveTools(agent, mode, participant, scope) {
64
+ if (!this.#runtime)
65
+ throw new Error('Tool inspection requires a configured Harness runtime.');
66
+ return this.#runtime.effectiveTools(this.pending(participant, agent).session(scope), mode);
67
+ }
68
+ pending(participant, agent, actor) {
69
+ validateAgent(agent);
70
+ const config = agent === null ? undefined : this.#agents[agent];
71
+ const ttl = config?.ttlSeconds === undefined ? this.#ttlSeconds : config.ttlSeconds;
72
+ return new PendingSession(participant, this.#stores, ttl, agent, actor, this.#participants, this.#maxAddresses, config?.defaultScope ?? this.#defaultScope);
73
+ }
74
+ tasks(list, agent = null) {
75
+ list ??= (agent === null ? undefined : this.#agents[agent]?.defaultTaskList) ?? this.#defaultTaskList;
76
+ const address = new SessionAddress(null, `task-list:${list}`, agent);
77
+ const durable = this.#stores.durable();
78
+ return new StoreTaskSource(agent === null ? durable : indexedStore(durable, new StoreAddressIndex(durable, this.#maxAddresses), address, 'durable', 'tasks'), agent === null ? `${list}:tasks` : `agent-list:${address.indexKey()}:tasks`);
79
+ }
80
+ sharedThread(scope, agent = null) {
81
+ const address = new SessionAddress(null, scope, agent), durable = this.#stores.durable();
82
+ return new Thread(agent === null ? durable : indexedStore(durable, new StoreAddressIndex(durable, this.#maxAddresses), address, 'durable', 'thread'), `thread:${address.threadKey()}`);
83
+ }
84
+ async findShared(scope, agent = null) {
85
+ const thread = this.sharedThread(scope, agent);
86
+ return await thread.exists() ? thread : null;
37
87
  }
38
88
  ephemeralStore() {
39
89
  return this.#stores.ephemeral();
@@ -46,10 +96,25 @@ export class PendingSession {
46
96
  participant;
47
97
  stores;
48
98
  ttlSeconds;
49
- constructor(participant, stores, ttlSeconds) {
99
+ agent;
100
+ actor;
101
+ participants;
102
+ maxAddresses;
103
+ defaultScope;
104
+ constructor(participant, stores, ttlSeconds, agent = null, actor, participants = new ParticipantAdapters(), maxAddresses = null, defaultScope = 'default') {
50
105
  this.participant = participant;
51
106
  this.stores = stores;
52
107
  this.ttlSeconds = ttlSeconds;
108
+ this.agent = agent;
109
+ this.actor = actor;
110
+ this.participants = participants;
111
+ this.maxAddresses = maxAddresses;
112
+ this.defaultScope = defaultScope;
113
+ }
114
+ asActor(actor) {
115
+ if (this.participant instanceof ParticipantRef)
116
+ throw new Error('Typed participants require a registered actor adapter.');
117
+ return new PendingSession(this.participant, this.stores, this.ttlSeconds, this.agent, actor, this.participants, this.maxAddresses, this.defaultScope);
53
118
  }
54
119
  /**
55
120
  * Resolve the session for a scope.
@@ -58,13 +123,43 @@ export class PendingSession {
58
123
  * guard fire when a session is opened rather than at some later moment when
59
124
  * an approval needs saving.
60
125
  */
61
- session(scope) {
126
+ session(scope = this.defaultScope) {
127
+ const actor = this.participant instanceof ParticipantRef ? this.participants.actor(this.participant, this.actor) : (this.actor ?? this.participant);
128
+ const durable = this.stores.durable();
62
129
  return new Session({
63
130
  participant: this.participant,
64
131
  scope,
65
132
  ephemeral: this.stores.ephemeral(),
66
- durable: this.stores.durable(),
133
+ durable,
67
134
  ttlSeconds: this.ttlSeconds,
135
+ agent: this.agent,
136
+ actor,
137
+ addressIndex: this.agent !== null || this.participant instanceof ParticipantRef ? new StoreAddressIndex(durable, this.maxAddresses) : undefined,
68
138
  });
69
139
  }
70
140
  }
141
+ /** A namespace-bound view, sharing stores without a mutable current-agent flag. */
142
+ export class AgentHarness {
143
+ harness;
144
+ agent;
145
+ constructor(harness, agent) {
146
+ this.harness = harness;
147
+ this.agent = agent;
148
+ validateAgent(agent);
149
+ Object.defineProperty(this, 'agent', { writable: false, configurable: false });
150
+ }
151
+ for(participant) { return this.harness.pending(participant, this.agent); }
152
+ effectiveTools(mode, participant, scope) { return this.harness.effectiveTools(this.agent, mode, participant, scope); }
153
+ tasks(list) { return this.harness.tasks(list, this.agent); }
154
+ sharedThread(scope) { return this.harness.sharedThread(scope, this.agent); }
155
+ findShared(scope) { return this.harness.findShared(scope, this.agent); }
156
+ }
157
+ function validateHarnessConfig(config) {
158
+ if (config.ttlSeconds !== undefined && config.ttlSeconds !== null && (!Number.isSafeInteger(config.ttlSeconds) || config.ttlSeconds < 0))
159
+ throw new TypeError('Agent ephemeral TTL must be nonnegative whole seconds or null.');
160
+ for (const key of ['defaultScope', 'defaultTaskList']) {
161
+ const value = config[key];
162
+ if (value !== undefined && (typeof value !== 'string' || Buffer.from(value, 'utf8').toString('utf8') !== value || (key === 'defaultTaskList' && value.length === 0)))
163
+ throw new TypeError('Agent scope/list defaults must be valid strings.');
164
+ }
165
+ }
package/dist/index.d.ts CHANGED
@@ -2,23 +2,28 @@ export { HarnessError } from './errors.js';
2
2
  export type { HarnessErrorCode, HarnessErrorOptions } from './errors.js';
3
3
  export type { JsonObject, JsonValue } from './json.js';
4
4
  export { isJsonObject } from './json.js';
5
- export type { AppendLogStore, Durability, SessionStore } from './stores/session-store.js';
5
+ export type { AppendLogStore, Durability, LockScope, SessionStore } from './stores/session-store.js';
6
6
  export { isDurable, supportsAppendLog } from './stores/session-store.js';
7
7
  export { MemorySessionStore } from './stores/memory-session-store.js';
8
8
  export { FileSessionStore } from './stores/file-session-store.js';
9
+ export type { PendingFileLockRelease } from './stores/file-owner-lock.js';
10
+ export type { FileLockIdentityStatus } from './stores/file-lock-identity.js';
9
11
  export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
10
12
  export type { StoreFactory, StoreManagerOptions } from './stores/store-manager.js';
11
13
  export { Session } from './session.js';
12
14
  export type { Participant, RunState, SessionOptions } from './session.js';
13
15
  export { Thread } from './thread.js';
14
16
  export type { ThreadMessage } from './thread.js';
15
- export { PendingSession, PrismHarness } from './harness.js';
16
- export type { HarnessOptions } from './harness.js';
17
+ export { AgentHarness, PendingSession, PrismHarness } from './harness.js';
18
+ export { ParticipantAdapters, ParticipantRef, SessionAddress } from './address.js';
19
+ export { AddressLimitExceeded, IndexLockScopeUnsupported, IndexRecoveryRequired, StoreAddressIndex } from './address-index.js';
20
+ export type { AddressIndexPage } from './address-index.js';
21
+ export type { AgentHarnessConfig, HarnessOptions } from './harness.js';
17
22
  export { AgentMode, ModeRegistry } from './modes.js';
18
23
  export type { ModeConfig, ModeRegistryConfig } from './modes.js';
19
24
  export { MAX_DEPTH, RunBudget, RunContext, RunLedger, Subagent, subagentFromConfig } from './subagents.js';
20
25
  export { ToolAuthorizer, ToolRegistry, authorizedTool } from './tools.js';
21
- export type { CallPolicy, HarnessTool, OfferPolicy, ToolAuthorizerOptions, ToolFactory, ToolProvider, } from './tools.js';
26
+ export type { CallPolicy, HarnessTool, OfferPolicy, ToolAuthorizerOptions, ToolFactory, RegistryOptions, ToolProvider, } from './tools.js';
22
27
  export { HarnessEvents } from './events.js';
23
28
  export type { HarnessEvent, HarnessListener, RunFailed, RunFinished, RunStarted } from './events.js';
24
29
  export { diagnose } from './doctor.js';
@@ -26,7 +31,7 @@ export type { DoctorFinding, DoctorReport, DoctorSubjects } from './doctor.js';
26
31
  export { AgentRuntime, recordApproval } from './runtime.js';
27
32
  export { assistantRow, threadView, toolResultEntry, toolResultRow } from './thread-rows.js';
28
33
  export type { ApprovalDecisionEntry, ApprovalRequestEntry, AssistantRow, ToolCallInput, ToolCallRow, ToolResultEntry, ToolResultRow, } from './thread-rows.js';
29
- export type { AgentResponse, AgentRuntimeOptions, LlmClient, LlmRequest, LlmResponse, LlmToolCall, PendingApproval, StructuredAgentResponse, } from './runtime.js';
34
+ export type { AgentResponse, AgentRuntimeOptions, EffectiveTool, RuntimeConfig, LlmClient, LlmRequest, LlmResponse, LlmToolCall, PendingApproval, StructuredAgentResponse, } from './runtime.js';
30
35
  export { schemaName, schemaProblems } from './structured.js';
31
36
  export { DEFAULT_LEASE_SECONDS, TASK_STATES, asAgentTask, attributesToRecord, canonicalTaskJson, isTaskState, isTerminalState, recordToAttributes, taskRecordFrom, withAgentTask, } from './tasks.js';
32
37
  export type { AgentTask, AgentTaskAttributeSource, AgentTaskAttributes, AgentTaskRecord, AgentTaskSource, LeasedAgentTask, TaskOutcome, TaskState, } from './tasks.js';
@@ -38,4 +43,5 @@ export { SkillRegistry } from './skills.js';
38
43
  export { SubagentRunner } from './subagent-runner.js';
39
44
  export type { SubagentOutcome, SubagentResult } from './subagent-runner.js';
40
45
  export { admitAttachments } from './attachments.js';
46
+ export { RegistrationCollision } from './registration.js';
41
47
  export type { Attachment, MediaLike } from './attachments.js';
package/dist/index.js CHANGED
@@ -6,7 +6,9 @@ export { FileSessionStore } from './stores/file-session-store.js';
6
6
  export { SessionStoreManager, SLOT_DURABLE, SLOT_EPHEMERAL } from './stores/store-manager.js';
7
7
  export { Session } from './session.js';
8
8
  export { Thread } from './thread.js';
9
- export { PendingSession, PrismHarness } from './harness.js';
9
+ export { AgentHarness, PendingSession, PrismHarness } from './harness.js';
10
+ export { ParticipantAdapters, ParticipantRef, SessionAddress } from './address.js';
11
+ export { AddressLimitExceeded, IndexLockScopeUnsupported, IndexRecoveryRequired, StoreAddressIndex } from './address-index.js';
10
12
  export { AgentMode, ModeRegistry } from './modes.js';
11
13
  export { MAX_DEPTH, RunBudget, RunContext, RunLedger, Subagent, subagentFromConfig } from './subagents.js';
12
14
  export { ToolAuthorizer, ToolRegistry, authorizedTool } from './tools.js';
@@ -21,3 +23,4 @@ export { agentCompletionTool } from './task-tools.js';
21
23
  export { SkillRegistry } from './skills.js';
22
24
  export { SubagentRunner } from './subagent-runner.js';
23
25
  export { admitAttachments } from './attachments.js';
26
+ export { RegistrationCollision } from './registration.js';
package/dist/modes.d.ts CHANGED
@@ -11,6 +11,9 @@ export interface ModeConfig {
11
11
  provider_options?: Record<string, unknown>;
12
12
  }
13
13
  export interface ModeRegistryConfig {
14
+ refuseDuplicates?: boolean;
15
+ owner?: string;
16
+ agents?: Record<string, ModeRegistryConfig>;
14
17
  default?: string;
15
18
  modes?: Record<string, ModeConfig>;
16
19
  }
@@ -53,7 +56,13 @@ export declare class AgentMode {
53
56
  export declare class ModeRegistry {
54
57
  #private;
55
58
  private readonly config;
56
- constructor(config?: ModeRegistryConfig);
59
+ private readonly agent;
60
+ private readonly root?;
61
+ constructor(config?: ModeRegistryConfig, agent?: string | null, root?: ModeRegistry | undefined);
62
+ forAgent(agent: string | null): ModeRegistry;
63
+ register(name: string, mode: ModeConfig, owner?: string): this;
64
+ replace(name: string, mode: ModeConfig, predecessorOwner: string, owner?: string): this;
65
+ setDefault(name: string): this;
57
66
  default(): string;
58
67
  /** Every mode name this application has configured. */
59
68
  names(): string[];
package/dist/modes.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { HarnessError } from './errors.js';
2
2
  import { isJsonObject } from './json.js';
3
3
  import { subagentFromConfig } from './subagents.js';
4
+ import { validateAgent } from './address.js';
5
+ import { RegistrationCollision, registrationOwner } from './registration.js';
4
6
  /**
5
7
  * What a mode lets a run do.
6
8
  *
@@ -47,8 +49,58 @@ export class AgentMode {
47
49
  }
48
50
  export class ModeRegistry {
49
51
  config;
50
- constructor(config = {}) {
52
+ agent;
53
+ root;
54
+ #agents = new Map();
55
+ #owners = new Map();
56
+ constructor(config = {}, agent = null, root) {
51
57
  this.config = config;
58
+ this.agent = agent;
59
+ this.root = root;
60
+ if (config.refuseDuplicates !== undefined && typeof config.refuseDuplicates !== 'boolean')
61
+ throw new TypeError('refuseDuplicates must be a boolean');
62
+ validateAgent(agent);
63
+ for (const name of Object.keys(config.modes ?? {}))
64
+ this.#owners.set(name, registrationOwner(config.owner, agent));
65
+ for (const key of ['config', 'agent', 'root'])
66
+ Object.defineProperty(this, key, { writable: false, configurable: false });
67
+ }
68
+ forAgent(agent) {
69
+ validateAgent(agent);
70
+ const root = this.root ?? this;
71
+ if (agent === null)
72
+ return root;
73
+ let view = root.#agents.get(agent);
74
+ if (!view) {
75
+ const configured = root.config.agents?.[agent] ?? {};
76
+ view = new ModeRegistry({ ...structuredClone(configured), refuseDuplicates: root.config.refuseDuplicates, default: configured.default ?? root.default() }, agent, root);
77
+ root.#agents.set(agent, view);
78
+ }
79
+ return view;
80
+ }
81
+ register(name, mode, owner) {
82
+ const label = registrationOwner(owner, this.agent), previous = this.#owners.get(name);
83
+ if ((this.agent !== null || this.config.refuseDuplicates === true) && previous !== undefined)
84
+ throw new RegistrationCollision(this.agent, name, previous, label);
85
+ this.#set(name, mode, label);
86
+ return this;
87
+ }
88
+ replace(name, mode, predecessorOwner, owner) {
89
+ const label = registrationOwner(owner, this.agent);
90
+ if (this.#owners.get(name) !== predecessorOwner)
91
+ throw new Error('Replacement must name the registered predecessor owner.');
92
+ this.#set(name, mode, label);
93
+ return this;
94
+ }
95
+ #set(name, mode, owner) {
96
+ this.config.modes ??= {};
97
+ this.config.modes[name] = structuredClone(mode);
98
+ this.#owners.set(name, owner);
99
+ }
100
+ setDefault(name) {
101
+ this.resolve(name);
102
+ this.config.default = name;
103
+ return this;
52
104
  }
53
105
  default() {
54
106
  const configured = this.config.default;
@@ -0,0 +1,6 @@
1
+ export declare class RegistrationCollision extends Error {
2
+ readonly code = "registration_collision";
3
+ constructor(agent: string | null, name: string, previous: string, incoming: string);
4
+ }
5
+ /** Diagnostic labels are host configuration, never permission grants. */
6
+ export declare function registrationOwner(owner: string | undefined, agent: string | null): string;
@@ -0,0 +1,15 @@
1
+ export class RegistrationCollision extends Error {
2
+ code = 'registration_collision';
3
+ constructor(agent, name, previous, incoming) {
4
+ super(`Harness registry collision: agent [${agent ?? 'legacy'}], name [${name}], owners [${previous}] and [${incoming}]. Use replace naming predecessor owner [${previous}].`);
5
+ }
6
+ }
7
+ /** Diagnostic labels are host configuration, never permission grants. */
8
+ export function registrationOwner(owner, agent) {
9
+ const label = owner ?? (agent === null ? 'legacy' : undefined);
10
+ if (typeof label !== 'string' || label.length === 0 || Buffer.byteLength(label) > 128
11
+ || Buffer.from(label, 'utf8').toString('utf8') !== label || /[\u0000-\u001f\u007f]/.test(label)) {
12
+ throw new TypeError('A named registration requires a non-empty UTF-8 owner label of at most 128 bytes without control characters.');
13
+ }
14
+ return label;
15
+ }
package/dist/runtime.d.ts CHANGED
@@ -16,6 +16,8 @@ import type { HarnessTool, ToolAuthorizer, ToolRegistry } from './tools.js';
16
16
  * The reference couples these because Prism is already a dependency there.
17
17
  */
18
18
  export interface LlmRequest {
19
+ /** Optional ceiling the model client must pass to its provider request. */
20
+ maxOutputTokens?: number;
19
21
  systemPrompt: string;
20
22
  /** The conversation so far, serialized — oldest first. */
21
23
  messages: readonly JsonObject[];
@@ -108,15 +110,34 @@ export interface PendingApproval {
108
110
  arguments: JsonObject;
109
111
  }
110
112
  export interface AgentRuntimeOptions {
113
+ /** Legacy compatibility default. Named agents always enforce their mode ceiling. */
114
+ narrowRequestedTools?: boolean;
115
+ config?: RuntimeConfig;
116
+ agents?: Record<string, RuntimeConfig>;
111
117
  client: LlmClient;
112
118
  modes: ModeRegistry;
113
119
  tools: ToolRegistry;
114
120
  authorizer?: ToolAuthorizer;
115
121
  events?: HarnessEvents;
116
122
  }
123
+ export interface RuntimeConfig {
124
+ provider?: string | null;
125
+ model?: string | null;
126
+ max_steps_ceiling?: number | null;
127
+ max_output_tokens_per_call?: number | null;
128
+ lock_ttl?: number;
129
+ lock_wait?: number;
130
+ }
131
+ export interface EffectiveTool {
132
+ name: string;
133
+ owner: string;
134
+ source: string;
135
+ }
117
136
  export declare class AgentRuntime {
118
137
  #private;
119
138
  constructor(options: AgentRuntimeOptions);
139
+ /** Inspect the offer without a run/handler or logical mutation; reads may purge expired entries. */
140
+ effectiveTools(session: Session, modeName?: string): Promise<EffectiveTool[]>;
120
141
  /**
121
142
  * Run a turn.
122
143
  *
@@ -149,19 +170,4 @@ export declare class AgentRuntime {
149
170
  */
150
171
  sendStructured(session: Session, prompt: string, schema: Readonly<JsonObject>, toolNames?: readonly string[], context?: RunContext, additionalContent?: readonly unknown[]): Promise<StructuredAgentResponse>;
151
172
  }
152
- /**
153
- * Answer a pending approval, durably.
154
- *
155
- * The decision is RECORDED IN THE THREAD, not held anywhere else. Who may
156
- * approve is the APPLICATION's decision, not this package's: the session is
157
- * already scoped to a participant, so nobody can answer another participant's
158
- * approval through it, but "this user may approve THIS action" is a question
159
- * only the host can answer. Authorize before calling.
160
- *
161
- * Nothing runs until the next `send()`, which acts on every decision recorded
162
- * by then and refuses any pending call still without one. With several pending,
163
- * record them all first.
164
- *
165
- * `approvalId` is `PendingApproval.id`, not the tool call id.
166
- */
167
173
  export declare function recordApproval(session: Session, approvalId: string, approved: boolean, reason?: string | null): Promise<void>;
package/dist/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { validateAgent } from './address.js';
2
3
  import { assistantRow, threadView, toolResultEntry, toolResultRow, } from './thread-rows.js';
3
4
  import { admitAttachments } from './attachments.js';
4
5
  import { HarnessError } from './errors.js';
@@ -32,12 +33,56 @@ export class AgentRuntime {
32
33
  #tools;
33
34
  #authorizer;
34
35
  #events;
36
+ #config;
37
+ #agents;
38
+ #narrowRequestedTools;
35
39
  constructor(options) {
40
+ if (options.narrowRequestedTools !== undefined && typeof options.narrowRequestedTools !== 'boolean')
41
+ throw new TypeError('narrowRequestedTools must be a boolean');
42
+ this.#narrowRequestedTools = options.narrowRequestedTools ?? false;
36
43
  this.#client = options.client;
37
44
  this.#modes = options.modes;
38
45
  this.#tools = options.tools;
39
46
  this.#authorizer = options.authorizer;
40
47
  this.#events = options.events;
48
+ this.#config = { ...options.config };
49
+ this.#agents = Object.fromEntries(Object.entries(options.agents ?? {}).map(([agent, config]) => {
50
+ validateAgent(agent);
51
+ validateRuntimeConfig(config);
52
+ return [agent, { ...config }];
53
+ }));
54
+ validateRuntimeConfig(this.#config);
55
+ }
56
+ #setting(session, key) {
57
+ const options = session.agent === null ? undefined : this.#agents[session.agent];
58
+ return options && Object.hasOwn(options, key) ? options[key] : this.#config[key];
59
+ }
60
+ #boundedRun(session, run) {
61
+ const steps = this.#setting(session, 'max_steps_ceiling'), output = this.#setting(session, 'max_output_tokens_per_call'), budget = run.budget;
62
+ if (steps == null && output == null)
63
+ return run;
64
+ return new RunContext(run.ledger, new RunBudget(steps == null ? budget.maxSteps : Math.min(budget.maxSteps, run.ledger.steps + steps), budget.maxCostUsd, budget.maxSeconds, output == null ? budget.maxOutputTokensPerCall : Math.min(budget.maxOutputTokensPerCall ?? output, output)), run.parentRunId, run.depth);
65
+ }
66
+ /** Inspect the offer without a run/handler or logical mutation; reads may purge expired entries. */
67
+ async effectiveTools(session, modeName) {
68
+ const mode = this.#modes.forAgent(session.agent).resolve(modeName ?? await session.mode());
69
+ return (await this.#offer(session, mode)).descriptors;
70
+ }
71
+ async #offer(session, mode, requested) {
72
+ return session.resolveReadOnly(mode.name, async (view) => {
73
+ const names = session.agent !== null || this.#narrowRequestedTools ? narrowedTools(mode.tools, requested) : (requested ?? mode.tools);
74
+ const detailed = await this.#tools.resolveDetailed(names, view);
75
+ const tools = new Map([...detailed].map(([name, entry]) => [name, entry.tool]));
76
+ if (view.agent !== null && tools.has('complete_task')) {
77
+ const authorizer = this.#authorizer?.forAgent(view.agent);
78
+ if (!authorizer?.enabled || !authorizer.hasCallPolicy())
79
+ throw new HarnessError('unsafe_authorization_configuration', 'Named task completion requires enabled authorization and an explicit call policy.');
80
+ }
81
+ const offered = this.#authorizer ? await this.#authorizer.allowed(view, tools) : [...tools.values()];
82
+ const ownership = new Map([...detailed.values()].map(entry => [entry.tool.name, entry]));
83
+ const descriptors = offered.map(tool => ({ name: tool.name, owner: ownership.get(tool.name).owner, source: ownership.get(tool.name).source }));
84
+ return { tools: offered, descriptors };
85
+ });
41
86
  }
42
87
  /**
43
88
  * Run a turn.
@@ -80,11 +125,11 @@ export class AgentRuntime {
80
125
  // Refused before a run exists: a bad attachment is a mistake in the call,
81
126
  // and it should not cost a run, events or budget.
82
127
  const attachments = admitAttachments(prompt, additionalContent);
83
- const mode = this.#modes.resolve(await session.mode());
84
- const provider = (await session.provider()) ?? 'unknown';
85
- const model = (await session.model()) ?? 'unknown';
128
+ const mode = this.#modes.forAgent(session.agent).resolve(await session.mode());
129
+ const provider = (await session.provider()) ?? this.#setting(session, 'provider') ?? 'unknown';
130
+ const model = (await session.model()) ?? this.#setting(session, 'model') ?? 'unknown';
86
131
  const runId = randomUUID();
87
- const run = context ?? RunContext.root(runId, new RunBudget(mode.maxSteps));
132
+ const run = this.#boundedRun(session, context ?? RunContext.root(runId, new RunBudget(mode.maxSteps)));
88
133
  const thread = session.thread();
89
134
  if (run.tooDeep()) {
90
135
  throw HarnessError.runNotPermitted(`This run is nested ${run.depth} deep, at or past the ceiling. Nobody debugs a tree that deep, ` +
@@ -103,8 +148,13 @@ export class AgentRuntime {
103
148
  at: new Date().toISOString(),
104
149
  });
105
150
  try {
106
- const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
107
- const offered = this.#authorizer ? await this.#authorizer.allowed(session, resolved) : [...resolved.values()];
151
+ let offered;
152
+ if (session.agent === null) {
153
+ const resolved = await this.#tools.resolve(toolNames ?? mode.tools, session);
154
+ offered = this.#authorizer ? await this.#authorizer.allowed(session, resolved) : [...resolved.values()];
155
+ }
156
+ else
157
+ offered = (await this.#offer(session, mode, toolNames)).tools;
108
158
  const called = [];
109
159
  // Decisions recorded since the run stopped are acted on FIRST, before a
110
160
  // new prompt is recorded, so the results land after the calls they answer
@@ -164,6 +214,7 @@ export class AgentRuntime {
164
214
  provider,
165
215
  model,
166
216
  providerOptions: mode.providerOptions,
217
+ ...(run.budget.maxOutputTokensPerCall === null ? {} : { maxOutputTokens: run.budget.maxOutputTokensPerCall }),
167
218
  ...(schema === undefined ? {} : { schema }),
168
219
  });
169
220
  run.ledger.recordSteps(1);
@@ -171,7 +222,8 @@ export class AgentRuntime {
171
222
  text = response.text;
172
223
  finishReason = response.finishReason;
173
224
  const toolCalls = response.toolCalls ?? [];
174
- const gated = toolCalls.filter((call) => mode.needsApproval(call.name));
225
+ const failedFinish = finishReason === 'error' || finishReason === 'unknown' ? finishReason : null;
226
+ const gated = failedFinish === null ? toolCalls.filter((call) => mode.needsApproval(call.name)) : [];
175
227
  const requests = gated.map((call) => ({
176
228
  approval_id: `apr_${randomUUID().replaceAll('-', '')}`,
177
229
  tool_call_id: call.id,
@@ -184,6 +236,14 @@ export class AgentRuntime {
184
236
  ? { ...(response.additionalContent ?? {}), structured: response.structured ?? null }
185
237
  : response.additionalContent;
186
238
  await thread.record([assistantRow(response.text, toolCalls, metadata, requests)], runId);
239
+ if (failedFinish !== null) {
240
+ // Complete the transcript with refusals, never approval requests, so a
241
+ // later resume cannot dispatch calls from this failed response.
242
+ if (toolCalls.length > 0) {
243
+ await thread.record([toolResultRow(toolCalls.map(call => toolResultEntry(call, `Not run: model step failed (${failedFinish}).`)))], runId);
244
+ }
245
+ throw HarnessError.modelStepFailed(failedFinish);
246
+ }
187
247
  if (toolCalls.length === 0) {
188
248
  if (schema !== undefined) {
189
249
  // Recorded first, then checked: the exchange happened either way, and
@@ -270,7 +330,7 @@ export class AgentRuntime {
270
330
  }
271
331
  }
272
332
  await live.thread().record([toolResultRow([...work.results.values(), ...resolved], [...work.decisions.values()])], runId);
273
- }, RESOLUTION_LOCK_SECONDS, RESOLUTION_WAIT_SECONDS);
333
+ }, this.#setting(session, 'lock_ttl') ?? RESOLUTION_LOCK_SECONDS, this.#setting(session, 'lock_wait') ?? RESOLUTION_WAIT_SECONDS);
274
334
  }
275
335
  /**
276
336
  * The gated calls of the last tool-calling turn that have no result yet, with
@@ -389,6 +449,33 @@ export class AgentRuntime {
389
449
  *
390
450
  * `approvalId` is `PendingApproval.id`, not the tool call id.
391
451
  */
452
+ function validateRuntimeConfig(config) {
453
+ for (const key of ['provider', 'model']) {
454
+ const value = config[key];
455
+ if (value !== undefined && value !== null && (typeof value !== 'string' || value === ''))
456
+ throw new TypeError('Agent provider/model defaults must be nonempty strings or null.');
457
+ }
458
+ for (const key of ['max_steps_ceiling', 'max_output_tokens_per_call']) {
459
+ const value = config[key];
460
+ if (value !== undefined && value !== null && (!Number.isSafeInteger(value) || value < 1))
461
+ throw new TypeError('Agent step/output ceilings must be positive integers or null.');
462
+ }
463
+ for (const key of ['lock_ttl', 'lock_wait']) {
464
+ const value = config[key];
465
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < (key === 'lock_ttl' ? 1 : 0)))
466
+ throw new TypeError('Agent lock settings must be whole seconds with positive TTL and nonnegative wait.');
467
+ }
468
+ }
469
+ function narrowedTools(ceiling, requested) {
470
+ if (requested === undefined || requested.includes('*'))
471
+ return ceiling;
472
+ if (ceiling.includes('*'))
473
+ return requested;
474
+ const refused = requested.find(name => !ceiling.includes(name));
475
+ if (refused !== undefined)
476
+ throw HarnessError.toolNotAvailable(refused, ceiling);
477
+ return requested;
478
+ }
392
479
  export async function recordApproval(session, approvalId, approved, reason = null) {
393
480
  const run = await session.run();
394
481
  await session.thread().record([toolResultRow([], [{ approval_id: approvalId, approved, reason }])], run?.id ?? null);
package/dist/session.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SessionAddress } from './address.js';
2
+ import { StoreAddressIndex } from './address-index.js';
1
3
  import type { JsonObject } from './json.js';
2
4
  import type { SessionStore } from './stores/session-store.js';
3
5
  import { StoreTaskSource } from './store-task-source.js';
@@ -29,6 +31,10 @@ export interface SessionOptions {
29
31
  durable: SessionStore;
30
32
  /** How long ephemeral state lives. Null keeps it until forgotten. */
31
33
  ttlSeconds?: number | null;
34
+ agent?: string | null;
35
+ actor?: object;
36
+ addressIndex?: StoreAddressIndex;
37
+ maxAddressesPerParticipant?: number | null;
32
38
  }
33
39
  /**
34
40
  * One participant's live runtime, reconstructed per turn.
@@ -47,6 +53,8 @@ export interface SessionOptions {
47
53
  */
48
54
  export declare class Session {
49
55
  #private;
56
+ readonly address: SessionAddress;
57
+ readonly actor: object;
50
58
  readonly scope: string;
51
59
  readonly participant: Participant;
52
60
  constructor(options: SessionOptions);
@@ -64,6 +72,7 @@ export declare class Session {
64
72
  * ONE store and resolve the same session.
65
73
  */
66
74
  key(): string;
75
+ get agent(): string | null;
67
76
  mode(): Promise<string | null>;
68
77
  usingMode(mode: string): Promise<this>;
69
78
  model(): Promise<string | null>;
@@ -75,6 +84,7 @@ export declare class Session {
75
84
  forget(): Promise<this>;
76
85
  /** The stored conversation this session is bound to. */
77
86
  thread(): Thread;
87
+ findThread(): Promise<Thread | null>;
78
88
  /**
79
89
  * The agent task list this session is bound to.
80
90
  *
@@ -111,4 +121,6 @@ export declare class Session {
111
121
  * run inside this, not outside it.
112
122
  */
113
123
  lock<T>(callback: (session: this) => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
124
+ /** Refuse explicit logical mutations; ordinary get() retains expired-entry cleanup. */
125
+ resolveReadOnly<T>(mode: string, resolve: (session: Session) => T | Promise<T>): Promise<T>;
114
126
  }