@purista/harness 1.5.0 → 1.5.1

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.
@@ -129,8 +129,7 @@ async function runDefaultAgentInner(args) {
129
129
  const outputSchema = args.agent.output ?? z.string();
130
130
  const parsedInput = parseAgentSchema(inputSchema, args.input, 'agent_input');
131
131
  const selectedModelAlias = args.modelAlias ?? args.agent.model;
132
- const model = args.models[selectedModelAlias];
133
- if (!model)
132
+ if (!args.models[selectedModelAlias])
134
133
  throw new ValidationError('Unknown model alias', { where: 'agent_input', issues: { model: selectedModelAlias } });
135
134
  const skillIds = args.agent.skills ?? [];
136
135
  await mountSkillsOnce(args.session, args.mountedSkills, args.skills, skillIds);
@@ -175,6 +174,7 @@ async function runDefaultAgentInner(args) {
175
174
  });
176
175
  const mcpSpecs = args.mcpRegistry ? await getMcpToolSpecs(args.customTools, enabledCustomTools, { registry: args.mcpRegistry, signal: args.signal, toolTimeoutMs: args.toolTimeoutMs, sandbox: args.session, sandboxKey: args.sessionId }) : [];
177
176
  const customSpecs = [...tsCustomSpecs, ...mcpSpecs];
177
+ const allToolSpecs = [...builtinSpecs, ...customSpecs];
178
178
  const nonSystem = args.history.filter((m) => m.role !== 'system');
179
179
  const system = args.history.filter((m) => m.role === 'system');
180
180
  const cappedNonSystem = args.historyWindow === undefined ? nonSystem : args.historyWindow === 0 ? [] : nonSystem.slice(-args.historyWindow);
@@ -201,19 +201,42 @@ async function runDefaultAgentInner(args) {
201
201
  throw abortError(args.signal, 'run', 'Run was cancelled.');
202
202
  if (steps >= maxSteps)
203
203
  throw new AgentLoopBudgetError('Agent loop budget exceeded.', { agent_id: args.agentId, reason: 'iterations_exceeded', limit: maxSteps });
204
+ const prepared = await args.agent.prepareStep?.({
205
+ input: parsedInput,
206
+ runId: args.runId,
207
+ sessionId: args.sessionId,
208
+ history: { list: async () => args.history },
209
+ memory: args.memory,
210
+ checkpoints: args.checkpoints,
211
+ metadata: args.metadata ?? {},
212
+ metrics: args.metrics,
213
+ step: steps,
214
+ model: selectedModelAlias,
215
+ messages: modelMessages,
216
+ tools: allToolSpecs
217
+ });
218
+ const stepModelAlias = prepared?.model ?? selectedModelAlias;
219
+ const model = args.models[stepModelAlias];
220
+ if (!model)
221
+ throw new ValidationError('Unknown model alias', { where: 'agent_input', issues: { model: stepModelAlias } });
222
+ const stepTools = filterActiveTools(allToolSpecs, prepared?.activeTools, args.agentId);
223
+ const stepMessages = prepared?.messages ? [...prepared.messages] : modelMessages;
224
+ const stepInstructions = prepared?.instructions ?? instructions;
204
225
  const response = await model.object({
205
226
  messages: [
206
- { role: 'system', content: instructions },
207
- ...modelMessages
227
+ { role: 'system', content: stepInstructions },
228
+ ...stepMessages
208
229
  ],
209
- tools: [...builtinSpecs, ...customSpecs],
210
- schema: z.toJSONSchema(outputSchema)
230
+ tools: stepTools,
231
+ schema: z.toJSONSchema(outputSchema),
232
+ ...(prepared?.call ? { call: prepared.call } : {})
211
233
  }, args.signal, {
212
234
  harnessName: args.harnessName,
213
235
  sessionId: args.sessionId,
214
236
  runId: args.runId,
215
237
  ...(args.workflowId ? { workflowId: args.workflowId } : {}),
216
- agentId: args.agentId
238
+ agentId: args.agentId,
239
+ modelAlias: stepModelAlias
217
240
  });
218
241
  // Emit one usage-bearing model event per model round-trip (including
219
242
  // tool-call steps) so run-summary modelCalls and tokenTotals are accurate
@@ -223,11 +246,17 @@ async function runDefaultAgentInner(args) {
223
246
  runId: args.runId,
224
247
  agentId: args.agentId,
225
248
  ...(args.workflowId ? { workflowId: args.workflowId } : {}),
226
- modelAlias: selectedModelAlias,
249
+ modelAlias: stepModelAlias,
227
250
  object: (response.object ?? null),
228
251
  usage: response.usage
229
252
  });
230
253
  const toolCalls = (response.toolCalls ?? []);
254
+ if (await shouldStopAgentLoop(args, parsedInput, stepModelAlias, steps, modelMessages, allToolSpecs, response, toolCalls)) {
255
+ const validated = parseAgentSchema(outputSchema, response.object, 'agent_output');
256
+ emitted.push({ id: `msg_${ulid()}_a`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: JSON.stringify(validated), timestamp: new Date().toISOString() });
257
+ await args.emitEvent?.({ type: 'agent.finished', runId: args.runId, agentId: args.agentId, at: new Date().toISOString(), output: validated, ...agentEventMeta });
258
+ return { output: validated, emitted };
259
+ }
231
260
  if (toolCalls.length === 0) {
232
261
  const validated = parseAgentSchema(outputSchema, response.object, 'agent_output');
233
262
  emitted.push({ id: `msg_${ulid()}_a`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: JSON.stringify(validated), timestamp: new Date().toISOString() });
@@ -262,6 +291,41 @@ async function runDefaultAgentInner(args) {
262
291
  throw error;
263
292
  }
264
293
  }
294
+ function filterActiveTools(tools, activeTools, agentId) {
295
+ if (!activeTools)
296
+ return [...tools];
297
+ const requested = new Set(activeTools);
298
+ const filtered = tools.filter((tool) => requested.has(tool.name));
299
+ if (filtered.length !== requested.size) {
300
+ const available = new Set(tools.map((tool) => tool.name));
301
+ const unknown = [...requested].filter((name) => !available.has(name));
302
+ throw new ValidationError('prepareStep referenced an unknown active tool.', {
303
+ where: 'agent_input',
304
+ issues: { agentId, activeTools: unknown }
305
+ });
306
+ }
307
+ return filtered;
308
+ }
309
+ async function shouldStopAgentLoop(args, input, selectedModelAlias, step, messages, tools, response, toolCalls) {
310
+ if (!args.agent.stopWhen)
311
+ return false;
312
+ return args.agent.stopWhen({
313
+ input,
314
+ runId: args.runId,
315
+ sessionId: args.sessionId,
316
+ history: { list: async () => args.history },
317
+ memory: args.memory,
318
+ checkpoints: args.checkpoints,
319
+ metadata: args.metadata ?? {},
320
+ metrics: args.metrics,
321
+ step,
322
+ model: selectedModelAlias,
323
+ messages,
324
+ tools,
325
+ response,
326
+ toolCalls
327
+ });
328
+ }
265
329
  /** Runs `fn` over `items` with bounded concurrency, preserving input order. */
266
330
  export async function runLimited(items, limit, fn) {
267
331
  const concurrency = Math.max(1, Math.min(limit, items.length));
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type Logger } from '../logger/index.js';
3
- import type { ModelAlias, ModelCapability, TokenUsage } from '../ports/model-provider.js';
3
+ import type { ModelAlias, ModelCapability, ObjectResponse, ToolCallSpec, ModelMessage, ModelToolSpec, TokenUsage, ModelCallOptions } from '../ports/model-provider.js';
4
4
  import type { StateStore } from '../ports/state.js';
5
5
  import type { Metrics, TelemetryShim } from '../telemetry/index.js';
6
6
  import type { HarnessAdapterContext } from '../ports/harness-context.js';
@@ -15,6 +15,7 @@ import type { HarnessError } from '../errors/harness-error.js';
15
15
  import { type Sandbox } from '../sandbox/index.js';
16
16
  import type { ModelHandle } from '../models/registry.js';
17
17
  import { type AdapterCapability, type DurableRuntimeAdapter, type HarnessInspection } from '../ports/capabilities.js';
18
+ import type { DurableStepOptions } from '../runtime/steps.js';
18
19
  /** Stable harness version string for diagnostics and generated documentation. */
19
20
  export { HARNESS_VERSION } from '../version.js';
20
21
  /** OpenTelemetry capture controls used by the harness. */
@@ -365,6 +366,41 @@ export interface AgentContextMinimal<S extends BuilderState, I> {
365
366
  metadata: Readonly<Record<string, JsonValue>>;
366
367
  metrics: Metrics;
367
368
  }
369
+ /** Context passed before each default agent loop model call. */
370
+ export interface AgentPrepareStepContext<S extends BuilderState, I> extends AgentContextMinimal<S, I> {
371
+ /** Zero-based model-call step in the default loop. */
372
+ step: number;
373
+ /** Model alias selected for this step before overrides are applied. */
374
+ model: keyof NonNullable<S['models']> & string;
375
+ /** Messages that would be sent to the model for this step. */
376
+ messages: readonly ModelMessage[];
377
+ /** Model-facing tools that would be available for this step. */
378
+ tools: readonly ModelToolSpec[];
379
+ }
380
+ /** Per-step overrides returned from `AgentDefinition.prepareStep`. */
381
+ export interface AgentPrepareStepResult<S extends BuilderState> {
382
+ /** Optional model alias override for this model call. */
383
+ model?: keyof NonNullable<S['models']> & string;
384
+ /** Optional instruction override for this model call only. */
385
+ instructions?: string;
386
+ /** Optional model-facing tool names to keep active for this model call. */
387
+ activeTools?: readonly string[];
388
+ /** Optional message override for this model call only. */
389
+ messages?: readonly ModelMessage[];
390
+ /** Optional generation settings for this model call only. */
391
+ call?: ModelCallOptions;
392
+ }
393
+ /** Context passed after a default agent loop model call to decide whether to stop. */
394
+ export interface AgentStopWhenContext<S extends BuilderState, I> extends AgentPrepareStepContext<S, I> {
395
+ /** Raw provider-normalized object response from the current model call. */
396
+ response: ObjectResponse<JsonValue>;
397
+ /** Tool calls requested by the current model response. */
398
+ toolCalls: readonly ToolCallSpec[];
399
+ }
400
+ /** Hook used to prepare each model call in the default agent loop. */
401
+ export type AgentPrepareStep<S extends BuilderState, I> = (ctx: AgentPrepareStepContext<S, I>) => AgentPrepareStepResult<S> | Promise<AgentPrepareStepResult<S> | void> | void;
402
+ /** Hook used to stop the default loop after a model call. */
403
+ export type AgentStopWhen<S extends BuilderState, I> = (ctx: AgentStopWhenContext<S, I>) => boolean | Promise<boolean>;
368
404
  /** Run-bound facade for explicit long-horizon context checkpoints. */
369
405
  export interface ContextCheckpoints {
370
406
  write(input: {
@@ -404,7 +440,7 @@ export interface WorkflowContext<S extends BuilderState, I, O> {
404
440
  * checkpointed and replayed on resume without re-running `fn`; otherwise it is
405
441
  * a transparent pass-through. See spec 10 "Durable steps".
406
442
  */
407
- step<T extends JsonValue>(stepId: string, fn: () => Promise<T>): Promise<T>;
443
+ step<T extends JsonValue>(stepId: string, fn: () => Promise<T>, options?: DurableStepOptions): Promise<T>;
408
444
  output?: O;
409
445
  }
410
446
  /** Invoke options accepted by workflow-local child-agent calls. */
@@ -434,6 +470,24 @@ export interface AgentDefinition<S extends BuilderState, I extends z.ZodTypeAny
434
470
  permissions?: AgentPermissions;
435
471
  onPermission?: OnPermission;
436
472
  maxSteps?: number;
473
+ /**
474
+ * Optional hook for per-round loop control in the default agent loop.
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * prepareStep: ({ step }) => step === 0 ? { activeTools: ['lookup'] } : {}
479
+ * ```
480
+ */
481
+ prepareStep?: AgentPrepareStep<S, z.infer<I>>;
482
+ /**
483
+ * Optional hook that can stop the default loop after a model call.
484
+ *
485
+ * @example
486
+ * ```ts
487
+ * stopWhen: ({ step }) => step >= 2
488
+ * ```
489
+ */
490
+ stopWhen?: AgentStopWhen<S, z.infer<I>>;
437
491
  handler?: (ctx: AgentContext<S, z.infer<I>, z.infer<O>>) => Promise<z.infer<O>>;
438
492
  }
439
493
  /** Workflow definition registered inline within `.workflows(...)`. */
@@ -458,6 +512,8 @@ type AgentDefinitionResolved<S extends BuilderState, I extends z.ZodTypeAny, O e
458
512
  permissions?: AgentPermissions;
459
513
  onPermission?: OnPermission;
460
514
  maxSteps?: number;
515
+ prepareStep?: AgentPrepareStep<S, z.infer<I>>;
516
+ stopWhen?: AgentStopWhen<S, z.infer<I>>;
461
517
  handler?: (ctx: AgentContext<S, z.infer<I>, z.infer<O>>) => Promise<z.infer<O>>;
462
518
  };
463
519
  type AgentDefinitionFor<S extends BuilderState, D> = D extends {
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export type { DurableReplayCheckpoint, DurableWorkspacePolicy, DurableWorkspaceS
25
25
  export { InMemoryDurableWorkspaceStore, inMemoryDurableWorkspaceStore } from './workspace/index.js';
26
26
  export type { ContextCheckpoint, ContextCheckpointQuery, ContextCheckpointRef, ContextCheckpointStore, ContextCheckpointStoreInfo } from './ports/context-checkpoints.js';
27
27
  export { createDurableWorkflowContext, DurableStepError, DurableRunLeaseError, DurableTerminalRunError, inMemoryDurableRuntime, isResumeBlockingRunStatus, isTerminalRunStatus } from './runtime/index.js';
28
- export type { DurableActiveRunStatus, DurableWorkflowContext, DurableWorkflowContextOptions, DurableStepCommit, DurableRunLease, DurableRunStart, DurableRunStatus, DurableRuntime, DurableTerminalRunStatus, FinishRunPatch, InMemoryDurableRuntimeOptions, RunCheckpoint } from './runtime/index.js';
28
+ export type { DurableActiveRunStatus, DurableWorkflowContext, DurableWorkflowContextOptions, DurableStepCommit, DurableStepOptions, DurableStepRetryPolicy, DurableStepRetrySetting, DurableRunLease, DurableRunStart, DurableRunStatus, DurableRuntime, DurableTerminalRunStatus, FinishRunPatch, InMemoryDurableRuntimeOptions, RunCheckpoint } from './runtime/index.js';
29
29
  export { bashSandbox, inMemorySandbox } from './sandbox/index.js';
30
30
  export type { ExecCapableSandboxSession, HibernateCapableSandbox, ResumeCapableSandbox, Sandbox, SandboxProcess, SandboxResumeOptions, SandboxSession, SandboxSessionBase, SandboxSessionFor, SnapshotCapableSandbox, SnapshotResult, SpawnCapableSandboxSession, SpawnOptions } from './sandbox/index.js';
31
31
  export type { DirEntry, ExecOptions, ExecResult, FileStat } from './harness/types.js';
@@ -35,4 +35,4 @@ export { discoverSkills } from './skills/index.js';
35
35
  export { evaluateDeterministicScorer, evaluatePromptCandidates } from './eval/index.js';
36
36
  export type { CandidateScore, DeterministicScorerDefinition, EvaluatePromptCandidatesInput, EvaluationItem, PromptCandidate, ScorerResult, ScorerTarget } from './eval/index.js';
37
37
  export { defineHarness } from './harness/defineHarness.js';
38
- export type { AgentContext, AgentContextMinimal, AgentDefinition, AgentDefinitionHelpers, AgentInput, AgentInvoker, AgentOutput, AgentPermissions, AgentsConfig, BuilderState, BuiltinToolName, ContentCaptureMode, ContextCheckpoints, ConversationHistory, DelegationDefaults, DiscoveredSkills, DiscoverSkillsOptions, DurableInvokeOptions, Harness, HarnessBuilder, HarnessDefaults, HarnessOptions, InferTypes, InvokeOptions, McpAuth, McpHttpToolDefinition, McpStdioToolDefinition, ModelHandles, ModelsConfig, OnPermission, PermissionContext, PermissionDecision, PermissionMode, PermissionPolicy, ResolvedSkill, RunEvent, RunSummary, SerializedError, Session, SkillDefinition, SkillDiagnostic, SkillFrontmatter, SkillsConfig, SkillValidationMode, TelemetryFlavor, TelemetryOptions, ToolDefinition, ToolHandlerContext, ToolsConfig, TsToolDefinition, WorkflowAgentInvokeOptions, WorkflowContext, WorkflowDefinition, WorkflowDefinitionHelpers, WorkflowDelegationPolicy, WorkflowInput, WorkflowInvoker, WorkflowOutput, WorkflowsConfig } from './harness/defineHarness.js';
38
+ export type { AgentContext, AgentContextMinimal, AgentDefinition, AgentDefinitionHelpers, AgentInput, AgentInvoker, AgentOutput, AgentPermissions, AgentPrepareStep, AgentPrepareStepContext, AgentPrepareStepResult, AgentStopWhen, AgentStopWhenContext, AgentsConfig, BuilderState, BuiltinToolName, ContentCaptureMode, ContextCheckpoints, ConversationHistory, DelegationDefaults, DiscoveredSkills, DiscoverSkillsOptions, DurableInvokeOptions, Harness, HarnessBuilder, HarnessDefaults, HarnessOptions, InferTypes, InvokeOptions, McpAuth, McpHttpToolDefinition, McpStdioToolDefinition, ModelHandles, ModelsConfig, OnPermission, PermissionContext, PermissionDecision, PermissionMode, PermissionPolicy, ResolvedSkill, RunEvent, RunSummary, SerializedError, Session, SkillDefinition, SkillDiagnostic, SkillFrontmatter, SkillsConfig, SkillValidationMode, TelemetryFlavor, TelemetryOptions, ToolDefinition, ToolHandlerContext, ToolsConfig, TsToolDefinition, WorkflowAgentInvokeOptions, WorkflowContext, WorkflowDefinition, WorkflowDefinitionHelpers, WorkflowDelegationPolicy, WorkflowInput, WorkflowInvoker, WorkflowOutput, WorkflowsConfig } from './harness/defineHarness.js';
@@ -18,6 +18,26 @@ export interface DurableWorkflowContextOptions {
18
18
  */
19
19
  readonly onStepCommit?: (commit: DurableStepCommit) => Promise<DurableReplayCheckpoint | undefined>;
20
20
  }
21
+ /** Retry policy for a single explicit workflow step. */
22
+ export type DurableStepRetrySetting = boolean | DurableStepRetryPolicy;
23
+ /** Provider-neutral retry policy for `ctx.step(...)` boundaries. */
24
+ export interface DurableStepRetryPolicy {
25
+ /** Total attempts including the first call. Default: `3`. */
26
+ readonly maxAttempts?: number;
27
+ /** Base delay before retrying in milliseconds. Default: `100`. */
28
+ readonly minDelayMs?: number;
29
+ /** Maximum delay before retrying in milliseconds. Default: `1_000`. */
30
+ readonly maxDelayMs?: number;
31
+ /** Delay strategy. Default: `exponential`. */
32
+ readonly backoff?: 'fixed' | 'exponential';
33
+ /** Optional predicate to suppress retries for non-transient failures. */
34
+ readonly shouldRetry?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
35
+ }
36
+ /** Per-call options for an explicit workflow step. */
37
+ export interface DurableStepOptions {
38
+ /** Retry failed step functions before a checkpoint is committed. Default: no retry. */
39
+ readonly retry?: DurableStepRetrySetting;
40
+ }
21
41
  /** Durable workflow context that exposes explicit checkpoint boundaries. */
22
42
  export interface DurableWorkflowContext {
23
43
  /** Current durable run lease. */
@@ -30,7 +50,7 @@ export interface DurableWorkflowContext {
30
50
  * const prepared = await ctx.step('prepare-inputs', async () => ({ ok: true }))
31
51
  * ```
32
52
  */
33
- step<T extends JsonValue>(stepId: string, fn: () => Promise<T>): Promise<T>;
53
+ step<T extends JsonValue>(stepId: string, fn: () => Promise<T>, options?: DurableStepOptions): Promise<T>;
34
54
  }
35
55
  /** Error thrown when a durable step id is invalid or duplicated. */
36
56
  export declare class DurableStepError extends Error {
@@ -38,3 +58,4 @@ export declare class DurableStepError extends Error {
38
58
  }
39
59
  /** Creates a durable workflow context bound to an acquired runtime lease. */
40
60
  export declare function createDurableWorkflowContext(runtime: DurableRuntime, lease: DurableRunLease, options?: DurableWorkflowContextOptions): DurableWorkflowContext;
61
+ export declare function runStepWithRetry<T>(fn: () => Promise<T>, retry: DurableStepRetrySetting | undefined): Promise<T>;
@@ -18,7 +18,7 @@ export function createDurableWorkflowContext(runtime, lease, options = {}) {
18
18
  let sequence = (lease.checkpoints ?? []).reduce((max, checkpoint) => Math.max(max, checkpoint.sequence), 0);
19
19
  return {
20
20
  lease,
21
- async step(stepId, fn) {
21
+ async step(stepId, fn, stepOptions = {}) {
22
22
  validateStepId(stepId);
23
23
  if (completed.has(stepId)) {
24
24
  throw new DurableStepError(`Duplicate durable step id "${stepId}".`);
@@ -29,7 +29,7 @@ export function createDurableWorkflowContext(runtime, lease, options = {}) {
29
29
  if (replay.has(stepId)) {
30
30
  return replay.get(stepId);
31
31
  }
32
- const output = await fn();
32
+ const output = await runStepWithRetry(fn, stepOptions.retry);
33
33
  assertJsonSerializable(output, stepId);
34
34
  sequence += 1;
35
35
  // Workspace state is written before the runtime checkpoint (spec 21 §10),
@@ -54,6 +54,57 @@ export function createDurableWorkflowContext(runtime, lease, options = {}) {
54
54
  }
55
55
  };
56
56
  }
57
+ export async function runStepWithRetry(fn, retry) {
58
+ const policy = normalizeRetryPolicy(retry);
59
+ let attempt = 0;
60
+ let lastError;
61
+ while (attempt < policy.maxAttempts) {
62
+ attempt += 1;
63
+ try {
64
+ return await fn();
65
+ }
66
+ catch (error) {
67
+ lastError = error;
68
+ if (attempt >= policy.maxAttempts)
69
+ break;
70
+ if (policy.shouldRetry && !await policy.shouldRetry(error, attempt))
71
+ break;
72
+ await sleep(retryDelayMs(policy, attempt));
73
+ }
74
+ }
75
+ throw lastError;
76
+ }
77
+ function normalizeRetryPolicy(retry) {
78
+ if (!retry) {
79
+ return { maxAttempts: 1, minDelayMs: 0, maxDelayMs: 0, backoff: 'fixed' };
80
+ }
81
+ if (retry === true) {
82
+ return { maxAttempts: 3, minDelayMs: 100, maxDelayMs: 1_000, backoff: 'exponential' };
83
+ }
84
+ return {
85
+ maxAttempts: clampPositiveInteger(retry.maxAttempts ?? 3),
86
+ minDelayMs: Math.max(0, retry.minDelayMs ?? 100),
87
+ maxDelayMs: Math.max(0, retry.maxDelayMs ?? 1_000),
88
+ backoff: retry.backoff ?? 'exponential',
89
+ ...(retry.shouldRetry ? { shouldRetry: retry.shouldRetry } : {})
90
+ };
91
+ }
92
+ function clampPositiveInteger(value) {
93
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 1;
94
+ }
95
+ function retryDelayMs(policy, attempt) {
96
+ if (policy.maxDelayMs === 0)
97
+ return 0;
98
+ const base = policy.backoff === 'fixed'
99
+ ? policy.minDelayMs
100
+ : policy.minDelayMs * 2 ** Math.max(0, attempt - 1);
101
+ return Math.min(policy.maxDelayMs, base);
102
+ }
103
+ function sleep(ms) {
104
+ if (ms <= 0)
105
+ return Promise.resolve();
106
+ return new Promise((resolve) => setTimeout(resolve, ms));
107
+ }
57
108
  function validateStepId(stepId) {
58
109
  if (!STEP_ID_PATTERN.test(stepId)) {
59
110
  throw new DurableStepError(`Invalid durable step id "${stepId}".`);
@@ -40,9 +40,9 @@ type HarnessDefinition<S extends BuilderState> = {
40
40
  * promise-notified rather than time-polled, so there is no fixed per-event
41
41
  * latency or periodic timer.
42
42
  *
43
- * Abandoning the stream (`break` / `iterator.return()`) aborts `relaySignal`,
44
- * so a run wired to it is cancelled promptly instead of blocking the consumer
45
- * until the run finishes on its own.
43
+ * Abandoning the stream (`break` / `iterator.return()`) only detaches that
44
+ * consumer. It does not abort `relaySignal`; callers must pass `opts.signal`
45
+ * when they intend to cancel the underlying run.
46
46
  */
47
47
  export declare function relayRunEvents(run: (onEvent: (event: RunEvent) => Promise<void>, relaySignal: AbortSignal) => Promise<unknown>): AsyncIterable<RunEvent>;
48
48
  export declare function createSessionHarness<S extends BuilderState>(definition: HarnessDefinition<S>): Harness<S>;
@@ -4,6 +4,7 @@ import { runDefaultAgent } from '../agents/index.js';
4
4
  import { runWorkflow } from '../workflows/index.js';
5
5
  import { createMemoryFacade, createSessionMemory } from '../ports/memory.js';
6
6
  import { beginDurableWorkflow, DURABLE_RUN_ID_PATTERN, isExecutableDurableRuntime } from '../runtime/sessionDurable.js';
7
+ import { runStepWithRetry } from '../runtime/steps.js';
7
8
  import { HarnessConfigError } from '../errors/catalog.js';
8
9
  import { loadSkillsSync } from '../skills/index.js';
9
10
  import { createModelRegistry } from '../models/registry.js';
@@ -49,9 +50,9 @@ const STREAM_UNDROPPABLE_EVENT_TYPES = new Set(['run.finished']);
49
50
  * promise-notified rather than time-polled, so there is no fixed per-event
50
51
  * latency or periodic timer.
51
52
  *
52
- * Abandoning the stream (`break` / `iterator.return()`) aborts `relaySignal`,
53
- * so a run wired to it is cancelled promptly instead of blocking the consumer
54
- * until the run finishes on its own.
53
+ * Abandoning the stream (`break` / `iterator.return()`) only detaches that
54
+ * consumer. It does not abort `relaySignal`; callers must pass `opts.signal`
55
+ * when they intend to cancel the underlying run.
55
56
  */
56
57
  export async function* relayRunEvents(run) {
57
58
  const queue = [];
@@ -61,6 +62,7 @@ export async function* relayRunEvents(run) {
61
62
  let failure;
62
63
  let wake;
63
64
  const relayController = new AbortController();
65
+ let completedNormally = false;
64
66
  const notify = () => {
65
67
  const resolve = wake;
66
68
  wake = undefined;
@@ -110,6 +112,7 @@ export async function* relayRunEvents(run) {
110
112
  }
111
113
  if (queue.length === 0 && dropped === 0) {
112
114
  if (done) {
115
+ completedNormally = true;
113
116
  break;
114
117
  }
115
118
  // No await between the empty check and installing `wake`, so a producer
@@ -121,10 +124,12 @@ export async function* relayRunEvents(run) {
121
124
  }
122
125
  }
123
126
  finally {
124
- // Cancel the run before awaiting it so an abandoned stream does not block
125
- // `iterator.return()` until the run finishes or times out.
126
- relayController.abort(new OperationCancelledError('Run event stream was abandoned by the consumer.', { scope: 'run' }));
127
- await result.catch(() => undefined);
127
+ if (completedNormally) {
128
+ await result.catch(() => undefined);
129
+ }
130
+ else {
131
+ void result.catch(() => undefined);
132
+ }
128
133
  }
129
134
  if (failure)
130
135
  throw failure;
@@ -922,8 +927,8 @@ export function createSessionHarness(definition) {
922
927
  }
923
928
  }
924
929
  /** Pass-through step used when a workflow runs without durable execution. */
925
- function passthroughStep(_stepId, fn) {
926
- return fn();
930
+ function passthroughStep(_stepId, fn, options = {}) {
931
+ return runStepWithRetry(fn, options.retry);
927
932
  }
928
933
  function resolveDelegationPolicy(workflow) {
929
934
  const configured = workflow.delegation;
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Harness package version, used as the OpenTelemetry instrumentation scope version. */
2
- export declare const HARNESS_VERSION = "1.5.0";
2
+ export declare const HARNESS_VERSION = "1.5.1";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Harness package version, used as the OpenTelemetry instrumentation scope version. */
2
- export const HARNESS_VERSION = '1.5.0';
2
+ export const HARNESS_VERSION = '1.5.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purista/harness",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Self-hosted enterprise agent harness for typed tools, agents, workflows, state, sandboxing, and telemetry.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -69,12 +69,12 @@
69
69
  },
70
70
  "devDependencies": {
71
71
  "@modelcontextprotocol/sdk": "^1.29.0",
72
- "@opentelemetry/context-async-hooks": "^2.7.1",
73
- "@types/node": "^25.9.1",
74
- "@vitest/coverage-v8": "^4.1.8",
72
+ "@opentelemetry/context-async-hooks": "^2.8.0",
73
+ "@types/node": "^25.9.3",
74
+ "@vitest/coverage-v8": "^4.1.9",
75
75
  "just-bash": "^3.0.1",
76
76
  "typescript": "^6.0.3",
77
- "vitest": "^4.1.8"
77
+ "vitest": "^4.1.9"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=24.15.0"