diff --git a/packages/sdk-py/src/agent_relay/client.py b/packages/sdk-py/src/agent_relay/client.py index fbdd7800e..72d76ea7b 100644 --- a/packages/sdk-py/src/agent_relay/client.py +++ b/packages/sdk-py/src/agent_relay/client.py @@ -557,6 +557,7 @@ async def spawn_pty( idle_threshold_secs: Optional[int] = None, restart_policy: Optional[dict[str, Any]] = None, continue_from: Optional[str] = None, + skip_relay_prompt: Optional[bool] = None, ) -> dict[str, Any]: await self.start_client() built_args = _build_pty_args_with_model(cli, args or [], model) @@ -585,6 +586,8 @@ async def spawn_pty( request_payload["idle_threshold_secs"] = idle_threshold_secs if continue_from is not None: request_payload["continue_from"] = continue_from + if skip_relay_prompt is not None: + request_payload["skip_relay_prompt"] = skip_relay_prompt return await self._request_ok("spawn_agent", request_payload) async def spawn_headless( @@ -595,6 +598,7 @@ async def spawn_headless( args: Optional[list[str]] = None, channels: Optional[list[str]] = None, task: Optional[str] = None, + skip_relay_prompt: Optional[bool] = None, ) -> dict[str, Any]: await self.start_client() agent = AgentSpec( @@ -607,6 +611,8 @@ async def spawn_headless( request_payload: dict[str, Any] = {"agent": agent.to_dict()} if task is not None: request_payload["initial_task"] = task + if skip_relay_prompt is not None: + request_payload["skip_relay_prompt"] = skip_relay_prompt return await self._request_ok("spawn_agent", request_payload) async def spawn_provider( @@ -626,6 +632,7 @@ async def spawn_provider( idle_threshold_secs: Optional[int] = None, restart_policy: Optional[dict[str, Any]] = None, continue_from: Optional[str] = None, + skip_relay_prompt: Optional[bool] = None, ) -> dict[str, Any]: resolved_transport: AgentTransport = transport or ( "headless" if provider == "opencode" else "pty" @@ -645,6 +652,7 @@ async def spawn_provider( args=args, channels=channels, task=task, + skip_relay_prompt=skip_relay_prompt, ) return await self.spawn_pty( @@ -661,6 +669,7 @@ async def spawn_provider( idle_threshold_secs=idle_threshold_secs, restart_policy=restart_policy, continue_from=continue_from, + skip_relay_prompt=skip_relay_prompt, ) async def spawn_claude(self, **kwargs: Any) -> dict[str, Any]: diff --git a/packages/sdk-py/src/agent_relay/relay.py b/packages/sdk-py/src/agent_relay/relay.py index 46beb77c8..f8db2e581 100644 --- a/packages/sdk-py/src/agent_relay/relay.py +++ b/packages/sdk-py/src/agent_relay/relay.py @@ -51,6 +51,7 @@ class SpawnOptions: shadow_mode: Optional[str] = None idle_threshold_secs: Optional[int] = None restart_policy: Optional[dict[str, Any]] = None + skip_relay_prompt: Optional[bool] = None on_start: LifecycleHook = None on_success: LifecycleHook = None on_error: LifecycleHook = None @@ -304,6 +305,7 @@ async def spawn( task: Optional[str] = None, model: Optional[str] = None, cwd: Optional[str] = None, + skip_relay_prompt: Optional[bool] = None, on_start: LifecycleHook = None, on_success: LifecycleHook = None, on_error: LifecycleHook = None, @@ -332,6 +334,7 @@ async def spawn( task=task, model=model, cwd=cwd, + skip_relay_prompt=skip_relay_prompt, ) except Exception as error: await self._relay._invoke_lifecycle_hook( @@ -512,6 +515,7 @@ async def spawn( shadow_mode=opts.shadow_mode, idle_threshold_secs=opts.idle_threshold_secs, restart_policy=opts.restart_policy, + skip_relay_prompt=opts.skip_relay_prompt, ) except Exception as error: await self._invoke_lifecycle_hook( diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 7216a352f..ca6661c1c 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -50,6 +50,9 @@ export interface SpawnPtyInput { restartPolicy?: RestartPolicy; /** Name of a previously released agent whose continuity context should be injected. */ continueFrom?: string; + /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent. + * Useful for minor tasks where relay messaging is not needed, saving tokens. */ + skipRelayPrompt?: boolean; } export interface SpawnHeadlessInput { @@ -58,6 +61,9 @@ export interface SpawnHeadlessInput { args?: string[]; channels?: string[]; task?: string; + /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent. + * Useful for minor tasks where relay messaging is not needed, saving tokens. */ + skipRelayPrompt?: boolean; } export type AgentTransport = 'pty' | 'headless'; @@ -77,6 +83,9 @@ export interface SpawnProviderInput { idleThresholdSecs?: number; restartPolicy?: RestartPolicy; continueFrom?: string; + /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent. + * Useful for minor tasks where relay messaging is not needed, saving tokens. */ + skipRelayPrompt?: boolean; } export interface SendMessageInput { @@ -278,6 +287,7 @@ export class AgentRelayClient { ...(input.task != null ? { initial_task: input.task } : {}), ...(input.idleThresholdSecs != null ? { idle_threshold_secs: input.idleThresholdSecs } : {}), ...(input.continueFrom != null ? { continue_from: input.continueFrom } : {}), + ...(input.skipRelayPrompt != null ? { skip_relay_prompt: input.skipRelayPrompt } : {}), }); return result; } @@ -294,6 +304,7 @@ export class AgentRelayClient { const result = await this.requestOk<{ name: string; runtime: AgentRuntime }>('spawn_agent', { agent, ...(input.task != null ? { initial_task: input.task } : {}), + ...(input.skipRelayPrompt != null ? { skip_relay_prompt: input.skipRelayPrompt } : {}), }); return result; } @@ -312,6 +323,7 @@ export class AgentRelayClient { args: input.args, channels: input.channels, task: input.task, + skipRelayPrompt: input.skipRelayPrompt, }); } @@ -329,6 +341,7 @@ export class AgentRelayClient { idleThresholdSecs: input.idleThresholdSecs, restartPolicy: input.restartPolicy, continueFrom: input.continueFrom, + skipRelayPrompt: input.skipRelayPrompt, }); } diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index cb1a62b91..04e6f3c0b 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -51,7 +51,7 @@ export type SdkToBroker = } | { type: 'spawn_agent'; - payload: { agent: AgentSpec; initial_task?: string }; + payload: { agent: AgentSpec; initial_task?: string; skip_relay_prompt?: boolean }; } | { type: 'send_message'; diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 64ae68e77..902b8aa34 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -137,6 +137,9 @@ export interface SpawnOptions extends SpawnLifecycleHooks { shadowMode?: string; idleThresholdSecs?: number; restartPolicy?: RestartPolicy; + /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent. + * Useful for minor tasks where relay messaging is not needed, saving tokens. */ + skipRelayPrompt?: boolean; } export interface SpawnAndWaitOptions extends SpawnOptions { @@ -202,6 +205,9 @@ export interface SpawnerSpawnOptions extends SpawnLifecycleHooks { task?: string; model?: string; cwd?: string; + /** When true, skip injecting the relay MCP configuration and protocol prompt into the spawned agent. + * Useful for minor tasks where relay messaging is not needed, saving tokens. */ + skipRelayPrompt?: boolean; } export type EventHook = ((value: T) => void) | null; @@ -369,6 +375,7 @@ export class AgentRelay { shadowMode: input.shadowMode, idleThresholdSecs: input.idleThresholdSecs, restartPolicy: input.restartPolicy, + skipRelayPrompt: input.skipRelayPrompt, }); } catch (error) { await this.invokeLifecycleHook( @@ -410,6 +417,7 @@ export class AgentRelay { shadowMode: options?.shadowMode, idleThresholdSecs: options?.idleThresholdSecs, restartPolicy: options?.restartPolicy, + skipRelayPrompt: options?.skipRelayPrompt, onStart: options?.onStart, onSuccess: options?.onSuccess, onError: options?.onError, @@ -1225,6 +1233,7 @@ export class AgentRelay { task, model: options?.model, cwd: options?.cwd, + skipRelayPrompt: options?.skipRelayPrompt, onStart: options?.onStart, onSuccess: options?.onSuccess, onError: options?.onError, @@ -1248,6 +1257,7 @@ export class AgentRelay { args, channels, task, + skipRelayPrompt: options?.skipRelayPrompt, }); } catch (error) { await this.invokeLifecycleHook( diff --git a/src/main.rs b/src/main.rs index 68665c262..459c5f19d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -521,6 +521,10 @@ struct SpawnPayload { /// Name of a previously released agent whose continuity context should be injected. #[serde(default)] continue_from: Option, + /// When true, skip injecting the relay MCP configuration into the spawned agent. + /// Useful for minor tasks where relay messaging is not needed, saving tokens. + #[serde(default)] + skip_relay_prompt: bool, } #[derive(Debug, Deserialize)] @@ -716,6 +720,7 @@ impl WorkerRegistry { parent: Option, idle_threshold_secs: Option, worker_relay_api_key: Option, + skip_relay_prompt: bool, workspace_id: Option, ) -> Result<()> { if self.workers.contains_key(&spec.name) { @@ -774,20 +779,27 @@ impl WorkerRegistry { }; // Build MCP config args for CLIs that support dynamic MCP configuration. - let cwd = spec.cwd.as_deref().unwrap_or("."); - // Pass the original CLI name (e.g. "cursor") so cursor-specific - // MCP config logic is triggered. `resolved_cli` may differ - // (parse_cli_command maps "cursor" → "agent"). - let mcp_args = configure_relaycast_mcp_with_token( - cli, - &spec.name, - self.env_value("RELAY_API_KEY"), - self.env_value("RELAY_BASE_URL"), - &effective_args, - Path::new(cwd), - worker_relay_api_key.as_deref(), - ) - .await?; + // When skip_relay_prompt is true, skip MCP config injection so the + // spawned agent does not receive relay protocol context (saves tokens + // for minor tasks where messaging is not needed). + let mcp_args = if skip_relay_prompt { + vec![] + } else { + let cwd = spec.cwd.as_deref().unwrap_or("."); + // Pass the original CLI name (e.g. "cursor") so cursor-specific + // MCP config logic is triggered. `resolved_cli` may differ + // (parse_cli_command maps "cursor" → "agent"). + configure_relaycast_mcp_with_token( + cli, + &spec.name, + self.env_value("RELAY_API_KEY"), + self.env_value("RELAY_BASE_URL"), + &effective_args, + Path::new(cwd), + worker_relay_api_key.as_deref(), + ) + .await? + }; let has_extra = bypass_flag.is_some() || !effective_args.is_empty() || !mcp_args.is_empty(); @@ -814,16 +826,20 @@ impl WorkerRegistry { command.arg(headless_provider_cli_name(provider)); // Build MCP config for headless provider agents. - let mcp_args = configure_relaycast_mcp_with_token( - headless_provider_cli_name(provider), - &spec.name, - self.env_value("RELAY_API_KEY"), - self.env_value("RELAY_BASE_URL"), - &spec.args, - Path::new(spec.cwd.as_deref().unwrap_or(".")), - worker_relay_api_key.as_deref(), - ) - .await?; + let mcp_args = if skip_relay_prompt { + vec![] + } else { + configure_relaycast_mcp_with_token( + headless_provider_cli_name(provider), + &spec.name, + self.env_value("RELAY_API_KEY"), + self.env_value("RELAY_BASE_URL"), + &spec.args, + Path::new(spec.cwd.as_deref().unwrap_or(".")), + worker_relay_api_key.as_deref(), + ) + .await? + }; if !spec.args.is_empty() || !mcp_args.is_empty() { command.arg("--"); @@ -844,15 +860,17 @@ impl WorkerRegistry { for (key, value) in &self.worker_env { command.env(key, value); } - if let Some(relay_key) = worker_relay_api_key { - // Keep RELAY_API_KEY as the workspace key and pass the - // pre-registered agent token separately for MCP servers that - // support session bootstrap. - command.env("RELAY_AGENT_TOKEN", relay_key); + if !skip_relay_prompt { + if let Some(relay_key) = worker_relay_api_key { + // Keep RELAY_API_KEY as the workspace key and pass the + // pre-registered agent token separately for MCP servers that + // support session bootstrap. + command.env("RELAY_AGENT_TOKEN", relay_key); + } + command.env("RELAY_AGENT_NAME", &spec.name); + command.env("RELAY_AGENT_TYPE", "agent"); + command.env("RELAY_STRICT_AGENT_NAME", "1"); } - command.env("RELAY_AGENT_NAME", &spec.name); - command.env("RELAY_AGENT_TYPE", "agent"); - command.env("RELAY_STRICT_AGENT_NAME", "1"); // Remove CLAUDECODE env var to prevent "nested session" detection // when spawning Claude Code agents from within a Claude Code session. command.env_remove("CLAUDECODE"); @@ -1631,6 +1649,7 @@ async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Result<()> { Some("Dashboard".to_string()), None, worker_relay_key.clone(), + false, None, ).await { Ok(()) => { @@ -2263,6 +2282,7 @@ async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Result<()> { Some("Relaycast".to_string()), None, worker_relay_key.clone(), + false, Some(workspace_id.clone()), ).await { Ok(()) => { @@ -3190,30 +3210,34 @@ async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Result<()> { continue; } - let worker_relay_key = match relaycast_http - .register_agent_token(&name, rst.spec.cli.as_deref()) - .await - { - Ok(token) => token, - Err(error) => { - match registration_retry_after_secs(&error) { - Some(retry_after_secs) => { - tracing::warn!( - worker = %name, - retry_after_secs, - error = %error, - "restart blocked by relaycast registration rate limit" - ); - } - None => { - tracing::error!( - worker = %name, - error = %error, - "failed to pre-register worker before restart" - ); + let worker_relay_key = if rst.skip_relay_prompt { + None + } else { + match relaycast_http + .register_agent_token(&name, rst.spec.cli.as_deref()) + .await + { + Ok(token) => Some(token), + Err(error) => { + match registration_retry_after_secs(&error) { + Some(retry_after_secs) => { + tracing::warn!( + worker = %name, + retry_after_secs, + error = %error, + "restart blocked by relaycast registration rate limit" + ); + } + None => { + tracing::error!( + worker = %name, + error = %error, + "failed to pre-register worker before restart" + ); + } } + continue; } - continue; } }; @@ -3222,7 +3246,8 @@ async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Result<()> { rst.spec.clone(), rst.parent.clone(), None, - Some(worker_relay_key), + worker_relay_key, + rst.skip_relay_prompt, None, ) .await @@ -3561,8 +3586,13 @@ async fn handle_sdk_frame( // was warmed by preflight_agents, this is an instant cache hit (<1ms). // If registration times out or fails retryably, we proceed without a // token — the agent self-registers via MCP on first connect. + // Skip pre-registration when skip_relay_prompt is true — the agent + // won't use relay messaging so there is no need to register it, and + // a registration failure should not abort the spawn. let mut preregistration_warning: Option = None; - let worker_relay_key = if let Some(http) = relaycast_http { + let worker_relay_key = if payload.skip_relay_prompt { + None + } else if let Some(http) = relaycast_http { const REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15); match tokio::time::timeout( REGISTRATION_TIMEOUT, @@ -3627,6 +3657,7 @@ async fn handle_sdk_frame( None, payload.idle_threshold_secs, worker_relay_key.clone(), + payload.skip_relay_prompt, None, ) .await?; @@ -3662,6 +3693,7 @@ async fn handle_sdk_frame( payload.agent.clone(), None, initial_task_for_supervisor, + payload.skip_relay_prompt, restart_policy, ); workers.metrics.on_spawn(&name); diff --git a/src/supervisor.rs b/src/supervisor.rs index d9f0c5e5a..20a2c2cff 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -57,6 +57,7 @@ struct RestartState { pub spec: AgentSpec, pub initial_task: Option, pub parent: Option, + pub skip_relay_prompt: bool, } /// Decision returned by the supervisor after an agent exits. @@ -72,6 +73,7 @@ pub struct PendingRestart { pub parent: Option, pub initial_task: Option, pub restart_count: u32, + pub skip_relay_prompt: bool, } /// Manages restart state for all supervised agents. @@ -99,6 +101,7 @@ impl Supervisor { spec: AgentSpec, parent: Option, initial_task: Option, + skip_relay_prompt: bool, policy: RestartPolicy, ) { self.states.insert( @@ -111,6 +114,7 @@ impl Supervisor { spec, initial_task, parent, + skip_relay_prompt, }, ); } @@ -187,6 +191,7 @@ impl Supervisor { parent: state.parent.clone(), initial_task: state.initial_task.clone(), restart_count: state.total_restarts + 1, + skip_relay_prompt: state.skip_relay_prompt, }, )) } else { @@ -257,7 +262,14 @@ mod tests { #[test] fn register_and_unregister() { let mut sup = Supervisor::new(); - sup.register("w1", test_spec("w1"), None, None, RestartPolicy::default()); + sup.register( + "w1", + test_spec("w1"), + None, + None, + false, + RestartPolicy::default(), + ); assert!(sup.is_supervised("w1")); sup.unregister("w1"); @@ -278,6 +290,7 @@ mod tests { test_spec("w1"), Some("lead".into()), Some("do stuff".into()), + true, RestartPolicy::default(), ); @@ -298,7 +311,7 @@ mod tests { max_consecutive_failures: 10, // high so this doesn't trigger ..Default::default() }; - sup.register("w1", test_spec("w1"), None, None, policy); + sup.register("w1", test_spec("w1"), None, None, false, policy); // First crash -> restart assert!(matches!( @@ -327,7 +340,7 @@ mod tests { max_restarts: 10, // high so this doesn't trigger ..Default::default() }; - sup.register("w1", test_spec("w1"), None, None, policy); + sup.register("w1", test_spec("w1"), None, None, false, policy); // Crash 1 -> consecutive=1, restart assert!(matches!( @@ -355,7 +368,7 @@ mod tests { max_restarts: 10, ..Default::default() }; - sup.register("w1", test_spec("w1"), None, None, policy); + sup.register("w1", test_spec("w1"), None, None, false, policy); // Two crashes sup.on_exit("w1", Some(1), None); @@ -378,7 +391,7 @@ mod tests { enabled: false, ..Default::default() }; - sup.register("w1", test_spec("w1"), None, None, policy); + sup.register("w1", test_spec("w1"), None, None, false, policy); let decision = sup.on_exit("w1", Some(1), None).unwrap(); assert!(matches!(decision, RestartDecision::PermanentlyDead { .. })); @@ -387,7 +400,14 @@ mod tests { #[test] fn released_agent_not_restarted() { let mut sup = Supervisor::new(); - sup.register("w1", test_spec("w1"), None, None, RestartPolicy::default()); + sup.register( + "w1", + test_spec("w1"), + None, + None, + false, + RestartPolicy::default(), + ); sup.unregister("w1"); // Should return None — not supervised @@ -406,6 +426,7 @@ mod tests { test_spec("w1"), Some("lead".into()), Some("task".into()), + true, policy, ); @@ -418,6 +439,7 @@ mod tests { assert_eq!(pending[0].1.parent.as_deref(), Some("lead")); assert_eq!(pending[0].1.initial_task.as_deref(), Some("task")); assert_eq!(pending[0].1.restart_count, 1); + assert!(pending[0].1.skip_relay_prompt); } #[test] @@ -427,7 +449,7 @@ mod tests { cooldown_ms: 60_000, // 60 seconds ..Default::default() }; - sup.register("w1", test_spec("w1"), None, None, policy); + sup.register("w1", test_spec("w1"), None, None, false, policy); sup.on_exit("w1", Some(1), None); @@ -439,7 +461,14 @@ mod tests { #[test] fn restart_count_tracks_total() { let mut sup = Supervisor::new(); - sup.register("w1", test_spec("w1"), None, None, RestartPolicy::default()); + sup.register( + "w1", + test_spec("w1"), + None, + None, + false, + RestartPolicy::default(), + ); assert_eq!(sup.restart_count("w1"), 0); @@ -452,6 +481,22 @@ mod tests { assert_eq!(sup.restart_count("w1"), 2); } + #[test] + fn pending_restarts_preserve_skip_relay_prompt() { + let mut sup = Supervisor::new(); + let policy = RestartPolicy { + cooldown_ms: 0, + ..Default::default() + }; + sup.register("w1", test_spec("w1"), None, None, true, policy); + + sup.on_exit("w1", Some(1), None); + + let pending = sup.pending_restarts(); + assert_eq!(pending.len(), 1); + assert!(pending[0].1.skip_relay_prompt); + } + #[test] fn restart_count_returns_zero_for_unknown() { let sup = Supervisor::new();