agent-relay 3.2.14 → 3.2.16

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.
Files changed (137) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +3859 -17164
  6. package/package.json +8 -8
  7. package/packages/acp-bridge/package.json +2 -2
  8. package/packages/config/package.json +1 -1
  9. package/packages/hooks/package.json +4 -4
  10. package/packages/memory/package.json +2 -2
  11. package/packages/openclaw/package.json +2 -2
  12. package/packages/policy/package.json +2 -2
  13. package/packages/sdk/dist/broker-path.d.ts +19 -0
  14. package/packages/sdk/dist/broker-path.d.ts.map +1 -0
  15. package/packages/sdk/dist/broker-path.js +71 -0
  16. package/packages/sdk/dist/broker-path.js.map +1 -0
  17. package/packages/sdk/dist/cli-registry.d.ts.map +1 -1
  18. package/packages/sdk/dist/cli-registry.js +10 -1
  19. package/packages/sdk/dist/cli-registry.js.map +1 -1
  20. package/packages/sdk/dist/client.d.ts +6 -1
  21. package/packages/sdk/dist/client.d.ts.map +1 -1
  22. package/packages/sdk/dist/client.js +18 -0
  23. package/packages/sdk/dist/client.js.map +1 -1
  24. package/packages/sdk/dist/communicate/adapters/index.d.ts +0 -5
  25. package/packages/sdk/dist/communicate/adapters/index.d.ts.map +1 -1
  26. package/packages/sdk/dist/communicate/adapters/index.js +0 -5
  27. package/packages/sdk/dist/communicate/adapters/index.js.map +1 -1
  28. package/packages/sdk/dist/communicate/adapters/pi.d.ts +0 -1
  29. package/packages/sdk/dist/communicate/adapters/pi.d.ts.map +1 -1
  30. package/packages/sdk/dist/communicate/adapters/pi.js +0 -4
  31. package/packages/sdk/dist/communicate/adapters/pi.js.map +1 -1
  32. package/packages/sdk/dist/communicate/core.d.ts.map +1 -1
  33. package/packages/sdk/dist/communicate/core.js +2 -3
  34. package/packages/sdk/dist/communicate/core.js.map +1 -1
  35. package/packages/sdk/dist/communicate/index.d.ts +17 -1
  36. package/packages/sdk/dist/communicate/index.d.ts.map +1 -1
  37. package/packages/sdk/dist/communicate/index.js +40 -1
  38. package/packages/sdk/dist/communicate/index.js.map +1 -1
  39. package/packages/sdk/dist/communicate/transport.d.ts +0 -1
  40. package/packages/sdk/dist/communicate/transport.d.ts.map +1 -1
  41. package/packages/sdk/dist/communicate/transport.js +42 -134
  42. package/packages/sdk/dist/communicate/transport.js.map +1 -1
  43. package/packages/sdk/dist/http.d.ts +38 -0
  44. package/packages/sdk/dist/http.d.ts.map +1 -0
  45. package/packages/sdk/dist/http.js +60 -0
  46. package/packages/sdk/dist/http.js.map +1 -0
  47. package/packages/sdk/dist/protocol.d.ts +25 -0
  48. package/packages/sdk/dist/protocol.d.ts.map +1 -1
  49. package/packages/sdk/dist/relay.d.ts +26 -3
  50. package/packages/sdk/dist/relay.d.ts.map +1 -1
  51. package/packages/sdk/dist/relay.js +62 -4
  52. package/packages/sdk/dist/relay.js.map +1 -1
  53. package/packages/sdk/dist/workflows/api-executor.d.ts +16 -0
  54. package/packages/sdk/dist/workflows/api-executor.d.ts.map +1 -0
  55. package/packages/sdk/dist/workflows/api-executor.js +94 -0
  56. package/packages/sdk/dist/workflows/api-executor.js.map +1 -0
  57. package/packages/sdk/dist/workflows/builder.d.ts +14 -0
  58. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  59. package/packages/sdk/dist/workflows/builder.js +26 -0
  60. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  61. package/packages/sdk/dist/workflows/cloud-runner.d.ts +15 -0
  62. package/packages/sdk/dist/workflows/cloud-runner.d.ts.map +1 -0
  63. package/packages/sdk/dist/workflows/cloud-runner.js +41 -0
  64. package/packages/sdk/dist/workflows/cloud-runner.js.map +1 -0
  65. package/packages/sdk/dist/workflows/index.d.ts +2 -0
  66. package/packages/sdk/dist/workflows/index.d.ts.map +1 -1
  67. package/packages/sdk/dist/workflows/index.js +1 -0
  68. package/packages/sdk/dist/workflows/index.js.map +1 -1
  69. package/packages/sdk/dist/workflows/run.d.ts.map +1 -1
  70. package/packages/sdk/dist/workflows/run.js +4 -0
  71. package/packages/sdk/dist/workflows/run.js.map +1 -1
  72. package/packages/sdk/dist/workflows/runner.d.ts +14 -0
  73. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  74. package/packages/sdk/dist/workflows/runner.js +154 -10
  75. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  76. package/packages/sdk/dist/workflows/types.d.ts +13 -3
  77. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  78. package/packages/sdk/dist/workflows/types.js +5 -1
  79. package/packages/sdk/dist/workflows/types.js.map +1 -1
  80. package/packages/sdk/dist/workflows/validator.d.ts.map +1 -1
  81. package/packages/sdk/dist/workflows/validator.js +12 -0
  82. package/packages/sdk/dist/workflows/validator.js.map +1 -1
  83. package/packages/sdk/package.json +13 -3
  84. package/packages/sdk/src/__tests__/channel-management.test.ts +131 -0
  85. package/packages/sdk/src/__tests__/communicate/core.test.ts +36 -88
  86. package/packages/sdk/src/__tests__/communicate/transport.test.ts +41 -80
  87. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +120 -0
  88. package/packages/sdk/src/__tests__/relay-channel-ops.test.ts +121 -0
  89. package/packages/sdk/src/__tests__/workflow-runner.test.ts +2 -2
  90. package/packages/sdk/src/broker-path.ts +74 -0
  91. package/packages/sdk/src/cli-registry.ts +10 -1
  92. package/packages/sdk/src/client.ts +28 -0
  93. package/packages/sdk/src/communicate/adapters/index.ts +0 -5
  94. package/packages/sdk/src/communicate/adapters/pi.ts +1 -5
  95. package/packages/sdk/src/communicate/core.ts +6 -10
  96. package/packages/sdk/src/communicate/index.ts +57 -1
  97. package/packages/sdk/src/communicate/transport.ts +46 -177
  98. package/packages/sdk/src/http.ts +96 -0
  99. package/packages/sdk/src/protocol.ts +24 -0
  100. package/packages/sdk/src/relay.ts +93 -8
  101. package/packages/sdk/src/workflows/README.md +5 -2
  102. package/packages/sdk/src/workflows/api-executor.ts +108 -0
  103. package/packages/sdk/src/workflows/builder.ts +40 -0
  104. package/packages/sdk/src/workflows/cloud-runner.ts +56 -0
  105. package/packages/sdk/src/workflows/index.ts +2 -0
  106. package/packages/sdk/src/workflows/run.ts +5 -0
  107. package/packages/sdk/src/workflows/runner.ts +181 -11
  108. package/packages/sdk/src/workflows/types.ts +19 -4
  109. package/packages/sdk/src/workflows/validator.ts +15 -0
  110. package/packages/sdk-py/README.md +7 -0
  111. package/packages/sdk-py/pyproject.toml +1 -1
  112. package/packages/sdk-py/src/agent_relay/__init__.py +2 -0
  113. package/packages/sdk-py/src/agent_relay/client.py +4 -0
  114. package/packages/sdk-py/src/agent_relay/communicate/adapters/__init__.py +0 -9
  115. package/packages/sdk-py/src/agent_relay/communicate/adapters/agno.py +5 -9
  116. package/packages/sdk-py/src/agent_relay/communicate/adapters/claude_sdk.py +5 -7
  117. package/packages/sdk-py/src/agent_relay/communicate/adapters/crewai.py +3 -13
  118. package/packages/sdk-py/src/agent_relay/communicate/adapters/google_adk.py +5 -2
  119. package/packages/sdk-py/src/agent_relay/communicate/adapters/openai_agents.py +5 -9
  120. package/packages/sdk-py/src/agent_relay/communicate/core.py +7 -24
  121. package/packages/sdk-py/src/agent_relay/communicate/transport.py +35 -212
  122. package/packages/sdk-py/src/agent_relay/communicate/types.py +1 -1
  123. package/packages/sdk-py/src/agent_relay/protocol.py +1 -0
  124. package/packages/sdk-py/src/agent_relay/relay.py +9 -1
  125. package/packages/sdk-py/tests/communicate/adapters/test_claude_sdk.py +6 -6
  126. package/packages/sdk-py/tests/communicate/conftest.py +86 -233
  127. package/packages/sdk-py/tests/communicate/integration/test_cross_framework.py +2 -2
  128. package/packages/sdk-py/tests/communicate/integration/test_end_to_end.py +14 -24
  129. package/packages/sdk-py/tests/communicate/test_transport.py +65 -54
  130. package/packages/sdk-py/tests/test_send_message_mode.py +91 -0
  131. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +323 -0
  132. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserverTypes.swift +143 -0
  133. package/packages/sdk-swift/Tests/AgentRelaySDKTests/RelayObserverTests.swift +526 -0
  134. package/packages/telemetry/package.json +1 -1
  135. package/packages/trajectory/package.json +2 -2
  136. package/packages/user-directory/package.json +2 -2
  137. package/packages/utils/package.json +2 -2
@@ -36,6 +36,7 @@ import {
36
36
  CustomStepResolutionError,
37
37
  } from './custom-steps.js';
38
38
  import { collectCliSession, type CliSessionReport } from './cli-session-collector.js';
39
+ import { executeApiStep } from './api-executor.js';
39
40
  import { InMemoryWorkflowDb } from './memory-db.js';
40
41
  import { formatRunSummaryTable } from './run-summary-table.js';
41
42
  import type {
@@ -180,6 +181,7 @@ export interface WorkflowRunnerOptions {
180
181
  cwd?: string;
181
182
  summaryDir?: string;
182
183
  executor?: StepExecutor;
184
+ envSecrets?: Record<string, string>;
183
185
  }
184
186
 
185
187
  // ── Step executor interface ──────────────────────────────────────────────────
@@ -202,6 +204,12 @@ export interface StepExecutor {
202
204
  resolvedCommand: string,
203
205
  cwd: string
204
206
  ): Promise<{ output: string; exitCode: number }>;
207
+
208
+ executeIntegrationStep?(
209
+ step: WorkflowStep,
210
+ resolvedParams: Record<string, string>,
211
+ context: { workspaceId?: string }
212
+ ): Promise<{ output: string; success: boolean }>;
205
213
  }
206
214
 
207
215
  // ── Variable context for template resolution ────────────────────────────────
@@ -297,6 +305,7 @@ export class WorkflowRunner {
297
305
  private readonly cwd: string;
298
306
  private readonly summaryDir: string;
299
307
  private readonly executor?: StepExecutor;
308
+ private readonly envSecrets?: Record<string, string>;
300
309
 
301
310
  /** @internal exposed for CLI signal-handler shutdown only */
302
311
  relay?: AgentRelay;
@@ -364,6 +373,7 @@ export class WorkflowRunner {
364
373
  this.summaryDir = options.summaryDir ?? path.join(this.cwd, '.relay', 'summaries');
365
374
  this.workersPath = path.join(this.cwd, '.agent-relay', 'team', 'workers.json');
366
375
  this.executor = options.executor;
376
+ this.envSecrets = options.envSecrets;
367
377
  }
368
378
 
369
379
  // ── Path resolution ─────────────────────────────────────────────────────
@@ -1502,7 +1512,7 @@ export class WorkflowRunner {
1502
1512
  // 6. Resource estimation
1503
1513
  const peakConcurrency = Math.max(...waves.map((w) => w.steps.length), 0);
1504
1514
  const totalAgentSteps = resolvedSteps.filter(
1505
- (s) => s.type !== 'deterministic' && s.type !== 'worktree'
1515
+ (s) => s.type !== 'deterministic' && s.type !== 'worktree' && s.type !== 'integration'
1506
1516
  ).length;
1507
1517
 
1508
1518
  // 7. Check maxConcurrency against wave widths
@@ -1559,6 +1569,14 @@ export class WorkflowRunner {
1559
1569
  if (typeof s.command !== 'string') {
1560
1570
  throw new Error(`${source}: deterministic step "${s.name}" must have a "command" field`);
1561
1571
  }
1572
+ } else if (s.type === 'integration') {
1573
+ // Integration steps require integration and action
1574
+ if (typeof s.integration !== 'string') {
1575
+ throw new Error(`${source}: integration step "${s.name}" must have an "integration" string field`);
1576
+ }
1577
+ if (typeof s.action !== 'string') {
1578
+ throw new Error(`${source}: integration step "${s.name}" must have an "action" string field`);
1579
+ }
1562
1580
  } else {
1563
1581
  // Agent steps (type undefined or 'agent') require agent and task
1564
1582
  if (typeof s.agent !== 'string' || typeof s.task !== 'string') {
@@ -1583,7 +1601,7 @@ export class WorkflowRunner {
1583
1601
 
1584
1602
  // Warn if non-interactive agent task is excessively large before interpolation
1585
1603
  for (const step of w.steps as WorkflowStep[]) {
1586
- if (step.type === 'deterministic' || step.type === 'worktree') continue;
1604
+ if (step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration') continue;
1587
1605
  const agentDef = agents.find((a) => a.name === step.agent);
1588
1606
  const isNonInteractive =
1589
1607
  agentDef?.interactive === false || ['worker', 'reviewer', 'analyst'].includes(agentDef?.preset ?? '');
@@ -1642,7 +1660,7 @@ export class WorkflowRunner {
1642
1660
 
1643
1661
  for (const step of steps) {
1644
1662
  // Only check interactive agent steps (leads)
1645
- if (step.type === 'deterministic' || step.type === 'worktree') continue;
1663
+ if (step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration') continue;
1646
1664
  const agentDef = agents.find((a) => a.name === step.agent);
1647
1665
  // Skip non-interactive agents — they can't wait for channel signals
1648
1666
  if (
@@ -1697,6 +1715,15 @@ export class WorkflowRunner {
1697
1715
  if (step.command) {
1698
1716
  step.command = this.interpolate(step.command, vars);
1699
1717
  }
1718
+ // Resolve variables in integration step params
1719
+ if (step.params && typeof step.params === 'object') {
1720
+ for (const key of Object.keys(step.params)) {
1721
+ const val = (step.params as Record<string, unknown>)[key];
1722
+ if (typeof val === 'string') {
1723
+ (step.params as Record<string, string>)[key] = this.interpolate(val, vars);
1724
+ }
1725
+ }
1726
+ }
1700
1727
  }
1701
1728
  }
1702
1729
  }
@@ -1841,22 +1868,24 @@ export class WorkflowRunner {
1841
1868
  // Build step rows
1842
1869
  const stepStates = new Map<string, StepState>();
1843
1870
  for (const step of resolvedWorkflow.steps) {
1844
- // Handle agent, deterministic, and worktree steps
1845
- const isNonAgent = step.type === 'deterministic' || step.type === 'worktree';
1871
+ // Handle agent, deterministic, worktree, and integration steps
1872
+ const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
1846
1873
 
1847
1874
  const stepRow: WorkflowStepRow = {
1848
1875
  id: this.generateId(),
1849
1876
  runId,
1850
1877
  stepName: step.name,
1851
1878
  agentName: isNonAgent ? null : (step.agent ?? null),
1852
- stepType: isNonAgent ? (step.type as 'deterministic' | 'worktree') : 'agent',
1879
+ stepType: isNonAgent ? (step.type as 'deterministic' | 'worktree' | 'integration') : 'agent',
1853
1880
  status: 'pending',
1854
1881
  task:
1855
1882
  step.type === 'deterministic'
1856
1883
  ? (step.command ?? '')
1857
1884
  : step.type === 'worktree'
1858
1885
  ? (step.branch ?? '')
1859
- : (step.task ?? ''),
1886
+ : step.type === 'integration'
1887
+ ? (`${step.integration}.${step.action}`)
1888
+ : (step.task ?? ''),
1860
1889
  dependsOn: step.dependsOn ?? [],
1861
1890
  retryCount: 0,
1862
1891
  createdAt: now,
@@ -2041,7 +2070,7 @@ export class WorkflowRunner {
2041
2070
  this.relayOptions.env?.AGENT_RELAY_WORKFLOW_DISABLE_RELAYCAST === '1';
2042
2071
  const requiresBroker =
2043
2072
  !this.executor &&
2044
- workflow.steps.some((step) => step.type !== 'deterministic' && step.type !== 'worktree');
2073
+ workflow.steps.some((step) => step.type !== 'deterministic' && step.type !== 'worktree' && step.type !== 'integration');
2045
2074
  // Skip broker/relay init when an external executor handles agent spawning
2046
2075
  if (requiresBroker) {
2047
2076
  if (!relaycastDisabled) {
@@ -2236,8 +2265,14 @@ export class WorkflowRunner {
2236
2265
  this.relaycast = undefined;
2237
2266
  this.relaycastAgent = undefined;
2238
2267
 
2239
- // Wire broker stderr to console for observability
2268
+ // Wire broker stderr to console for observability — skip empty and
2269
+ // JSON event lines (already surfaced via the broker:event emitter).
2240
2270
  this.unsubBrokerStderr = this.relay.onBrokerStderr((line: string) => {
2271
+ const trimmed = line.trim();
2272
+ if (!trimmed) return;
2273
+ // JSON event lines from the Rust EventEmitter are already parsed
2274
+ // and emitted as broker:event — no need to double-log them.
2275
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) return;
2241
2276
  console.log(`${chalk.dim.yellow('[broker]')} ${line}`);
2242
2277
  });
2243
2278
 
@@ -2684,6 +2719,11 @@ export class WorkflowRunner {
2684
2719
  return step.type === 'worktree';
2685
2720
  }
2686
2721
 
2722
+ /** Check if a step is an integration (external service) step. */
2723
+ private isIntegrationStep(step: WorkflowStep): boolean {
2724
+ return step.type === 'integration';
2725
+ }
2726
+
2687
2727
  private async executeStep(
2688
2728
  step: WorkflowStep,
2689
2729
  stepStates: Map<string, StepState>,
@@ -2701,6 +2741,11 @@ export class WorkflowRunner {
2701
2741
  return this.executeWorktreeStep(step, stepStates, runId);
2702
2742
  }
2703
2743
 
2744
+ // Branch: integration steps interact with external services
2745
+ if (this.isIntegrationStep(step)) {
2746
+ return this.executeIntegrationStep(step, stepStates, runId);
2747
+ }
2748
+
2704
2749
  // Agent step execution
2705
2750
  return this.executeAgentStep(step, stepStates, agentMap, errorHandling, runId);
2706
2751
  }
@@ -3194,6 +3239,76 @@ export class WorkflowRunner {
3194
3239
  }
3195
3240
  }
3196
3241
 
3242
+ /**
3243
+ * Execute an integration step (external service interaction via executor).
3244
+ */
3245
+ private async executeIntegrationStep(
3246
+ step: WorkflowStep,
3247
+ stepStates: Map<string, StepState>,
3248
+ runId: string
3249
+ ): Promise<void> {
3250
+ const state = stepStates.get(step.name);
3251
+ if (!state) throw new Error(`Step state not found: ${step.name}`);
3252
+
3253
+ this.checkAborted();
3254
+
3255
+ // Mark step as running
3256
+ state.row.status = 'running';
3257
+ state.row.error = undefined;
3258
+ state.row.completionReason = undefined;
3259
+ state.row.startedAt = new Date().toISOString();
3260
+ await this.db.updateStep(state.row.id, {
3261
+ status: 'running',
3262
+ error: undefined,
3263
+ completionReason: undefined,
3264
+ startedAt: state.row.startedAt,
3265
+ updatedAt: new Date().toISOString(),
3266
+ });
3267
+ this.emit({ type: 'step:started', runId, stepName: step.name });
3268
+ this.postToChannel(`**[${step.name}]** Started (integration: ${step.integration}.${step.action})`);
3269
+
3270
+ // Resolve {{steps.X.output}} in params
3271
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
3272
+ const resolvedParams: Record<string, string> = {};
3273
+ for (const [key, value] of Object.entries(step.params ?? {})) {
3274
+ resolvedParams[key] = this.interpolateStepTask(value, stepOutputContext);
3275
+ }
3276
+
3277
+ try {
3278
+ if (!this.executor?.executeIntegrationStep) {
3279
+ throw new Error(
3280
+ `Integration steps require a cloud executor. Step "${step.name}" cannot run locally. ` +
3281
+ `Use "cloud run" to execute workflows with integration steps.`
3282
+ );
3283
+ }
3284
+
3285
+ const result = await this.executor.executeIntegrationStep(step, resolvedParams, { workspaceId: this.workspaceId });
3286
+
3287
+ if (!result.success) {
3288
+ throw new Error(`Integration step "${step.name}" failed: ${result.output}`);
3289
+ }
3290
+
3291
+ // Mark completed
3292
+ state.row.status = 'completed';
3293
+ state.row.output = result.output;
3294
+ state.row.completedAt = new Date().toISOString();
3295
+ await this.db.updateStep(state.row.id, {
3296
+ status: 'completed',
3297
+ output: result.output,
3298
+ completedAt: state.row.completedAt,
3299
+ updatedAt: new Date().toISOString(),
3300
+ });
3301
+ await this.persistStepOutput(runId, step.name, result.output);
3302
+ this.emit({ type: 'step:completed', runId, stepName: step.name, output: result.output });
3303
+ this.postToChannel(`**[${step.name}]** Completed (integration: ${step.integration}.${step.action})`);
3304
+ } catch (err) {
3305
+ const errorMsg = err instanceof Error ? err.message : String(err);
3306
+ this.postToChannel(`**[${step.name}]** Failed: ${errorMsg}`);
3307
+ await this.markStepFailed(state, errorMsg, runId);
3308
+ throw new Error(`Step "${step.name}" failed: ${errorMsg}`);
3309
+ }
3310
+ }
3311
+
3197
3312
  /**
3198
3313
  * Execute an agent step (LLM-powered).
3199
3314
  */
@@ -3216,6 +3331,58 @@ export class WorkflowRunner {
3216
3331
  throw new Error(`Agent "${agentName}" not found in config`);
3217
3332
  }
3218
3333
  const specialistDef = WorkflowRunner.resolveAgentDef(rawAgentDef);
3334
+
3335
+ // API-mode agents: execute via direct API call instead of spawning a PTY/subprocess.
3336
+ if (specialistDef.cli === 'api') {
3337
+ const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
3338
+ const resolvedTask = this.interpolateStepTask(step.task ?? '', stepOutputContext);
3339
+
3340
+ state.row.status = 'running';
3341
+ state.row.startedAt = new Date().toISOString();
3342
+ await this.db.updateStep(state.row.id, {
3343
+ status: 'running',
3344
+ startedAt: state.row.startedAt,
3345
+ updatedAt: new Date().toISOString(),
3346
+ });
3347
+ this.emit({ type: 'step:started', runId, stepName: step.name });
3348
+ this.postToChannel(`**[${step.name}]** Started (api)`);
3349
+
3350
+ try {
3351
+ const output = await executeApiStep(
3352
+ specialistDef.constraints?.model ?? 'claude-sonnet-4-20250514',
3353
+ resolvedTask,
3354
+ { envSecrets: this.envSecrets, skills: specialistDef.skills, defaultMaxTokens: specialistDef.constraints?.maxTokens },
3355
+ );
3356
+
3357
+ state.row.status = 'completed';
3358
+ state.row.output = output;
3359
+ state.row.completedAt = new Date().toISOString();
3360
+ await this.db.updateStep(state.row.id, {
3361
+ status: 'completed',
3362
+ output,
3363
+ completedAt: state.row.completedAt,
3364
+ updatedAt: new Date().toISOString(),
3365
+ });
3366
+ await this.persistStepOutput(runId, step.name, output);
3367
+ this.emit({ type: 'step:completed', runId, stepName: step.name, output });
3368
+ } catch (apiError) {
3369
+ const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
3370
+ state.row.status = 'failed';
3371
+ state.row.error = errorMessage;
3372
+ state.row.completedAt = new Date().toISOString();
3373
+ await this.db.updateStep(state.row.id, {
3374
+ status: 'failed',
3375
+ error: errorMessage,
3376
+ completedAt: state.row.completedAt,
3377
+ updatedAt: new Date().toISOString(),
3378
+ });
3379
+ this.emit({ type: 'step:failed', runId, stepName: step.name, error: errorMessage });
3380
+ this.postToChannel(`**[${step.name}]** Failed (api): ${errorMessage}`);
3381
+ throw apiError;
3382
+ }
3383
+ return;
3384
+ }
3385
+
3219
3386
  const usesOwnerFlow = specialistDef.interactive !== false;
3220
3387
  const currentPattern = this.currentConfig?.swarm?.pattern ?? '';
3221
3388
  const isHubPattern = WorkflowRunner.HUB_PATTERNS.has(currentPattern);
@@ -4734,10 +4901,13 @@ export class WorkflowRunner {
4734
4901
  task: string,
4735
4902
  extraArgs: string[] = []
4736
4903
  ): { cmd: string; args: string[] } {
4904
+ if (cli === 'api') {
4905
+ throw new Error('cli "api" uses direct API calls, not a subprocess command');
4906
+ }
4737
4907
  const resolvedCli: AgentCli = cli === 'cursor' ? resolveCursorCli() : cli;
4738
4908
  const def = getCliDefinition(resolvedCli);
4739
- if (!def) {
4740
- throw new Error(`Unknown CLI: ${resolvedCli}`);
4909
+ if (!def || def.binaries.length === 0) {
4910
+ throw new Error(`Unknown or non-executable CLI: ${resolvedCli}`);
4741
4911
  }
4742
4912
  return {
4743
4913
  cmd: def.binaries[0],
@@ -145,9 +145,11 @@ export interface AgentDefinition {
145
145
  * analyst → interactive: false, reads code/files, writes findings, no sub-agents
146
146
  */
147
147
  preset?: AgentPreset;
148
+ /** System prompt / skills for API-mode agents (cli: 'api'). */
149
+ skills?: string;
148
150
  }
149
151
 
150
- export type AgentCli = 'claude' | 'codex' | 'gemini' | 'aider' | 'goose' | 'opencode' | 'droid' | 'cursor' | 'cursor-agent' | 'agent';
152
+ export type AgentCli = 'claude' | 'codex' | 'gemini' | 'aider' | 'goose' | 'opencode' | 'droid' | 'cursor' | 'cursor-agent' | 'agent' | 'api';
151
153
 
152
154
  /** Resource and behavioral constraints for an agent. */
153
155
  export interface AgentConstraints {
@@ -183,8 +185,8 @@ export interface WorkflowDefinition {
183
185
  onError?: 'fail' | 'skip' | 'retry';
184
186
  }
185
187
 
186
- /** Step type: agent (LLM-powered), deterministic (shell command), or worktree (git worktree setup). */
187
- export type WorkflowStepType = 'agent' | 'deterministic' | 'worktree';
188
+ /** Step type: agent (LLM-powered), deterministic (shell command), worktree (git worktree setup), or integration (external service). */
189
+ export type WorkflowStepType = 'agent' | 'deterministic' | 'worktree' | 'integration';
188
190
 
189
191
  // ── Custom step definitions ─────────────────────────────────────────────────
190
192
 
@@ -277,6 +279,14 @@ export interface WorkflowStep {
277
279
  /** Capture stdout as step output for downstream steps. Default: true. */
278
280
  captureOutput?: boolean;
279
281
 
282
+ // ── Integration step fields ────────────────────────────────────────────────
283
+ /** Integration name: 'github', 'linear', 'slack' (required for integration steps). */
284
+ integration?: string;
285
+ /** Action within the integration, e.g. 'create-pr', 'create-branch' (required for integration steps). */
286
+ action?: string;
287
+ /** Action parameters, supports {{steps.X.output}} interpolation. */
288
+ params?: Record<string, string>;
289
+
280
290
  // ── Worktree step fields ──────────────────────────────────────────────────
281
291
  /** Branch name for the worktree (required for worktree steps). */
282
292
  branch?: string;
@@ -298,6 +308,11 @@ export function isWorktreeStep(step: WorkflowStep): boolean {
298
308
  return step.type === 'worktree';
299
309
  }
300
310
 
311
+ /** Type guard: Check if a step is an integration (external service) step. */
312
+ export function isIntegrationStep(step: WorkflowStep): boolean {
313
+ return step.type === 'integration';
314
+ }
315
+
301
316
  /** Type guard: Check if a step uses a custom step definition. */
302
317
  export function isCustomStep(step: WorkflowStep): boolean {
303
318
  return step.use !== undefined;
@@ -305,7 +320,7 @@ export function isCustomStep(step: WorkflowStep): boolean {
305
320
 
306
321
  /** Type guard: Check if a step is an agent (LLM-powered) step. */
307
322
  export function isAgentStep(step: WorkflowStep): boolean {
308
- return step.type !== 'deterministic' && step.type !== 'worktree';
323
+ return step.type !== 'deterministic' && step.type !== 'worktree' && step.type !== 'integration';
309
324
  }
310
325
 
311
326
  // Legacy type aliases for backward compatibility
@@ -8,8 +8,23 @@ export interface ValidationIssue {
8
8
  location?: string; // e.g. "step:analyze" or "agent:analyst"
9
9
  }
10
10
 
11
+ const CHANNEL_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
12
+
11
13
  export function validateWorkflow(config: RelayYamlConfig): ValidationIssue[] {
12
14
  const issues: ValidationIssue[] = [];
15
+
16
+ // Validate channel name format (must be lowercase alphanumeric + hyphens)
17
+ const channel = (config as any).swarm?.channel ?? (config as any).channel;
18
+ if (channel && !CHANNEL_NAME_RE.test(channel)) {
19
+ issues.push({
20
+ severity: 'error',
21
+ code: 'INVALID_CHANNEL_NAME',
22
+ message: `Channel name "${channel}" is invalid. Must be lowercase alphanumeric and hyphens, starting with a letter or number.`,
23
+ fix: `Use .toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') on the channel name.`,
24
+ location: 'swarm:channel',
25
+ });
26
+ }
27
+
13
28
  const agentMap = new Map(config.agents.map((a) => [a.name, a]));
14
29
  const hasReviewerAgent = config.agents.some((a) => {
15
30
  const role = a.role?.toLowerCase() ?? '';
@@ -127,6 +127,13 @@ relay = Relay("Researcher")
127
127
  await relay.send("Lead", "Status update")
128
128
  await relay.post("docs", "Wave 5.1 complete")
129
129
  messages = await relay.inbox()
130
+
131
+ human = relay.system()
132
+ await human.send_message(
133
+ to="Agent1",
134
+ text="Please start the analysis",
135
+ mode="wait", # or "steer"
136
+ )
130
137
  ```
131
138
 
132
139
  ### `on_relay()`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-relay-sdk"
7
- version = "3.2.14"
7
+ version = "3.2.16"
8
8
  description = "Python SDK for Agent Relay workflows"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -17,6 +17,7 @@ from .protocol import (
17
17
  AgentRuntime,
18
18
  AgentSpec,
19
19
  BrokerEvent,
20
+ MessageInjectionMode,
20
21
  ProtocolEnvelope,
21
22
  RestartPolicy as ProtocolRestartPolicy,
22
23
  )
@@ -92,6 +93,7 @@ __all__ = [
92
93
  "AgentRuntime",
93
94
  "AgentSpec",
94
95
  "BrokerEvent",
96
+ "MessageInjectionMode",
95
97
  "ProtocolEnvelope",
96
98
  "ProtocolRestartPolicy",
97
99
  # Workflow builder (backward compat)
@@ -25,6 +25,7 @@ from .protocol import (
25
25
  AgentSpec,
26
26
  BrokerEvent,
27
27
  HeadlessProvider,
28
+ MessageInjectionMode,
28
29
  ProtocolEnvelope,
29
30
  )
30
31
 
@@ -715,6 +716,7 @@ class AgentRelayClient:
715
716
  thread_id: Optional[str] = None,
716
717
  priority: Optional[int] = None,
717
718
  data: Optional[dict[str, Any]] = None,
719
+ mode: Optional[MessageInjectionMode] = None,
718
720
  ) -> dict[str, Any]:
719
721
  await self.start_client()
720
722
  payload: dict[str, Any] = {"to": to, "text": text}
@@ -726,6 +728,8 @@ class AgentRelayClient:
726
728
  payload["priority"] = priority
727
729
  if data is not None:
728
730
  payload["data"] = data
731
+ if mode is not None:
732
+ payload["mode"] = mode
729
733
  try:
730
734
  return await self._request_ok("send_message", payload)
731
735
  except AgentRelayProtocolError as e:
@@ -1,10 +1 @@
1
1
  """Framework-specific adapters for on_relay()."""
2
-
3
- _has_pi = False
4
- try:
5
- from .pi import on_relay as on_pi_relay
6
- _has_pi = True
7
- except ImportError:
8
- pass
9
-
10
- __all__ = [*(["on_pi_relay"] if _has_pi else [])]
@@ -9,10 +9,10 @@ if TYPE_CHECKING:
9
9
 
10
10
 
11
11
  def _format_instructions_with_inbox(messages: list[Any], base_instructions: str) -> str:
12
- content = "\n\nNew messages from other agents:\n"
12
+ content = "New messages from other agents:\n"
13
13
  for message in messages:
14
14
  content += f" {message.sender}: {message.text}\n"
15
- return f"{content}\n{base_instructions}" if base_instructions else content
15
+ return f"{base_instructions}\n\n{content}" if base_instructions else content
16
16
 
17
17
 
18
18
  def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
@@ -45,11 +45,8 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
45
45
 
46
46
  agent.tools.extend([relay_send, relay_inbox, relay_post, relay_agents])
47
47
 
48
- # 2. Wrap instructions with a local buffer so we don't starve relay_inbox tool
48
+ # 2. Wrap instructions
49
49
  orig_instructions = agent.instructions
50
- pending_messages: list[Any] = []
51
-
52
- relay.on_message(lambda msg: pending_messages.append(msg))
53
50
 
54
51
  async def instructions_wrapper(*args: Any, **kwargs: Any) -> str:
55
52
  if callable(orig_instructions):
@@ -63,11 +60,10 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
63
60
  base = orig_instructions
64
61
 
65
62
  base = base or ""
66
- if not pending_messages:
63
+ messages = await relay.inbox()
64
+ if not messages:
67
65
  return base
68
66
 
69
- messages = list(pending_messages)
70
- pending_messages.clear()
71
67
  return _format_instructions_with_inbox(messages, base)
72
68
 
73
69
  agent.instructions = instructions_wrapper
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
9
9
  from ..core import Relay
10
10
 
11
11
 
12
- def on_relay(name: str, options: Any, relay: "Relay | None" = None) -> Any:
12
+ def on_relay(options: Any, relay: "Relay | None" = None, *, name: str | None = None) -> Any:
13
13
  """Wrap Claude Agent SDK query options to connect them to the relay."""
14
14
  try:
15
15
  from claude_agent_sdk.types import HookResult
@@ -24,10 +24,8 @@ def on_relay(name: str, options: Any, relay: "Relay | None" = None) -> Any:
24
24
  from ..core import Relay
25
25
  relay = Relay(agent_name)
26
26
 
27
- hooks = getattr(options, "hooks", None)
28
- if hooks is None:
29
- hooks = SimpleNamespace(post_tool_use=None, stop=None)
30
- options.hooks = hooks
27
+ if getattr(options, "hooks", None) is None:
28
+ options.hooks = SimpleNamespace(post_tool_use=None, stop=None)
31
29
 
32
30
  # 1. Inject Relaycast MCP server
33
31
  mcp_config = {"name": "relaycast", "command": "agent-relay", "args": ["mcp"]}
@@ -46,8 +44,8 @@ def on_relay(name: str, options: Any, relay: "Relay | None" = None) -> Any:
46
44
  return content
47
45
 
48
46
  # 3. Hook wrappers
49
- orig_post = getattr(hooks, "post_tool_use", None)
50
- orig_stop = getattr(hooks, "stop", None)
47
+ orig_post = options.hooks.post_tool_use if hasattr(options.hooks, "post_tool_use") else None
48
+ orig_stop = options.hooks.stop if hasattr(options.hooks, "stop") else None
51
49
 
52
50
  async def post_tool_use_hook(*args, **kwargs):
53
51
  res = None
@@ -47,18 +47,9 @@ class _RelayBackstory:
47
47
  def _resolve_sync(self) -> str:
48
48
  messages = self._drain_buffer()
49
49
  try:
50
- loop = asyncio.get_running_loop()
51
- except RuntimeError:
52
- loop = None
53
-
54
- if loop is None:
55
50
  messages.extend(self._relay.inbox_sync())
56
- else:
57
- # Running inside an event loop — use a thread to avoid blocking
58
- import concurrent.futures
59
- with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
60
- polled = pool.submit(asyncio.run, self._relay.inbox()).result()
61
- messages.extend(polled)
51
+ except Exception:
52
+ pass # Buffer messages are still available
62
53
  return _format_backstory(self._dedupe(messages), self._base_backstory)
63
54
 
64
55
  async def _resolve_async(self) -> str:
@@ -136,8 +127,7 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
136
127
  def _buffer_message(message: Any) -> None:
137
128
  backstory_buffer.append(message)
138
129
 
139
- unsubscribe = relay.on_message(_buffer_message)
130
+ relay.on_message(_buffer_message)
140
131
  agent.backstory = _RelayBackstory(relay, agent.backstory or "", backstory_buffer)
141
- agent._relay_unsubscribe = unsubscribe # type: ignore[attr-defined]
142
132
 
143
133
  return agent
@@ -47,7 +47,10 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
47
47
  if inspect.isawaitable(orig_result):
48
48
  orig_result = await orig_result
49
49
 
50
- # Always inject relay messages, even if the original callback returned a result
50
+ # If original callback short-circuited (returned Content), respect that
51
+ if orig_result is not None:
52
+ return orig_result
53
+
51
54
  messages = await relay.inbox()
52
55
  if messages:
53
56
  from google.genai.types import Content, Part
@@ -63,7 +66,7 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
63
66
  )
64
67
  )
65
68
 
66
- return orig_result
69
+ return None
67
70
 
68
71
  agent.before_model_callback = relay_callback
69
72
  return agent
@@ -10,10 +10,10 @@ if TYPE_CHECKING:
10
10
 
11
11
 
12
12
  def _format_instructions_with_inbox(messages: list[Any], base_instructions: str) -> str:
13
- content = "\n\nNew messages from other agents:\n"
13
+ content = "New messages from other agents:\n"
14
14
  for message in messages:
15
15
  content += f" {message.sender}: {message.text}\n"
16
- return f"{content}\n{base_instructions}" if base_instructions else content
16
+ return f"{base_instructions}\n\n{content}" if base_instructions else content
17
17
 
18
18
 
19
19
  def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
@@ -57,11 +57,8 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
57
57
  function_tool(relay_agents)
58
58
  ])
59
59
 
60
- # 2. Wrap instructions with a local buffer so we don't starve relay_inbox tool
60
+ # 2. Wrap instructions
61
61
  orig_instructions = agent.instructions
62
- pending_messages: list[Any] = []
63
-
64
- relay.on_message(lambda msg: pending_messages.append(msg))
65
62
 
66
63
  async def instructions_wrapper(*args: Any, **kwargs: Any) -> str:
67
64
  if callable(orig_instructions):
@@ -75,11 +72,10 @@ def on_relay(agent: Any, relay: "Relay | None" = None) -> Any:
75
72
  base = orig_instructions
76
73
 
77
74
  base = base or ""
78
- if not pending_messages:
75
+ messages = await relay.inbox()
76
+ if not messages:
79
77
  return base
80
78
 
81
- messages = list(pending_messages)
82
- pending_messages.clear()
83
79
  return _format_instructions_with_inbox(messages, base)
84
80
 
85
81
  agent.instructions = instructions_wrapper