diff --git a/.changeset/agent-disable.md b/.changeset/agent-disable.md new file mode 100644 index 00000000..5637bb63 --- /dev/null +++ b/.changeset/agent-disable.md @@ -0,0 +1,14 @@ +--- +"@stoneforge/smithy": minor +--- + +Add agent disable/enable: park agents from dispatch and scheduling without removing them from the agent list. + +- New `disabled?: boolean` flag on `BaseAgentMetadata`. Default omitted (treated as enabled). +- New CLI commands `sf agent disable ` and `sf agent enable `. +- `sf agent list` appends `(disabled)` to the status column when set. +- `sf agent start ` refuses with a hint pointing at `sf agent enable`. +- Dispatch daemon, `getAvailableDirector` in the registry, and the steward scheduler all skip disabled agents when selecting candidates for new work. +- In-flight sessions are NOT terminated when an agent is disabled; only future work is blocked. + +Closes #58. diff --git a/apps/docs/src/content/docs/reference/cli-orchestration.mdx b/apps/docs/src/content/docs/reference/cli-orchestration.mdx index 56a5413b..b38dbbc7 100644 --- a/apps/docs/src/content/docs/reference/cli-orchestration.mdx +++ b/apps/docs/src/content/docs/reference/cli-orchestration.mdx @@ -26,6 +26,8 @@ sf agent list [options] | `--reportsTo ` | Filter by manager entity ID | | `--hasSession` | Only show agents with active sessions | +Disabled agents are listed alongside enabled ones, with `(disabled)` appended to their session-status column. Disabled is independent of `--status`: filtering by `--status idle` returns both disabled-and-idle and enabled-and-idle agents. + Alias: `sf agents` #### `sf agent show` @@ -97,6 +99,26 @@ sf agent stop [--no-graceful] [--reason ] | `--no-graceful` | Force immediate shutdown | | `-r, --reason ` | Reason for stopping | +#### `sf agent disable` + +```bash +sf agent disable +``` + +Mark an agent as disabled. The agent stays visible in `sf agent list` but is skipped by the dispatch daemon, agent pool selection, `getAvailableDirector`, and the steward scheduler. In-flight sessions are NOT terminated; only future work is blocked. `sf agent start` refuses on a disabled agent. + +When `sf serve smithy` is reachable on `STONEFORGE_API_URL` (default `http://localhost:3457`), the CLI routes the change through the HTTP API so the running scheduler reconciles immediately (a disabled steward unregisters its cron and event triggers without an orchestrator restart). When the server is unreachable, the CLI writes the metadata directly; the scheduler picks up the change on its next start via `registerAllStewards`. + +Re-enable with `sf agent enable `. + +#### `sf agent enable` + +```bash +sf agent enable +``` + +Clear the `disabled` flag on a previously-disabled agent. Same HTTP-first / direct-write reconciliation behaviour as `sf agent disable`. After enable, the agent is eligible for dispatch on the next tick and (if a steward) is re-registered with the scheduler. + #### `sf agent stream` ```bash diff --git a/apps/docs/src/content/docs/reference/orchestrator-api.mdx b/apps/docs/src/content/docs/reference/orchestrator-api.mdx index 729e2e34..55679e0b 100644 --- a/apps/docs/src/content/docs/reference/orchestrator-api.mdx +++ b/apps/docs/src/content/docs/reference/orchestrator-api.mdx @@ -104,6 +104,46 @@ const availableWorkers = await api.getAvailableWorkers(); const allWorkers = await api.getAgentsByRole('worker'); ``` +### Disabled agents + +Agents carry an optional `disabled: boolean` flag on `BaseAgentMetadata`. When `true`, the agent stays visible in queries and listings but is skipped by: + +- `getAvailableDirector` (returns the next director that is `running` AND not disabled) +- The dispatch daemon's worker, steward, and orphan-recovery candidate-selection loops +- The steward scheduler's `registerSteward` (returns `false` for disabled stewards, so cron and event triggers are not registered) +- Session start and resume routes (`POST /api/agents/:id/start` and `POST /api/agents/:id/resume` return `409 AGENT_DISABLED`) + +The flag does NOT terminate in-flight sessions: an agent disabled while running keeps its session until it completes or is explicitly stopped. Only future work is blocked. + +Read the flag through helpers rather than touching metadata directly: + +```typescript +import { isAgentDisabled, isAgentEnabled, getAgentMetadata } from '@stoneforge/smithy'; + +const agent = await api.getAgent(agentId); +if (agent && isAgentDisabled(agent)) { + // Skip dispatch / start / register +} + +// Equivalent inverse: +if (agent && isAgentEnabled(agent)) { /* ... */ } +``` + +Toggle the flag through `updateAgentMetadata`. To enable an agent, omit the property entirely (do not set `disabled: false`) so the JSON-serialised metadata stays in the canonical absent-means-enabled shape: + +```typescript +// Disable +await api.updateAgentMetadata(agentId, { disabled: true }); + +// Enable (delete the property) +const meta = getAgentMetadata(agent); +const next = { ...meta }; +delete next.disabled; +await api.updateAgentMetadata(agentId, next); +``` + +The HTTP route `PATCH /api/agents/:id` accepts `{ "disabled": true | false }` and applies the same canonical shape internally; it additionally reconciles a running steward scheduler (calls `registerSteward` on enable, `unregisterSteward` on disable) so the change takes effect immediately. + --- ## Agent channels diff --git a/apps/smithy-web/src/api/hooks/useAgents.ts b/apps/smithy-web/src/api/hooks/useAgents.ts index 192ea8c8..eeace80c 100644 --- a/apps/smithy-web/src/api/hooks/useAgents.ts +++ b/apps/smithy-web/src/api/hooks/useAgents.ts @@ -490,6 +490,27 @@ export function useChangeAgentTriggers() { }); } +/** + * Hook to disable or enable an agent. + * Disabled agents stay in the list but are skipped by dispatch and the steward scheduler. + */ +export function useToggleAgentDisabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ agentId, disabled }) => { + return fetchApi(`/agents/${agentId}`, { + method: 'PATCH', + body: JSON.stringify({ disabled }), + }); + }, + onSuccess: (_, { agentId }) => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }); + }, + }); +} + /** * Hook to delete an agent */ diff --git a/apps/smithy-web/src/api/types.ts b/apps/smithy-web/src/api/types.ts index 5c2f007d..2124244a 100644 --- a/apps/smithy-web/src/api/types.ts +++ b/apps/smithy-web/src/api/types.ts @@ -48,6 +48,8 @@ export interface BaseAgentMetadata { provider?: string; model?: string; executablePath?: string; + /** When true, the agent is parked: kept in the list but skipped by dispatch and the scheduler. */ + disabled?: boolean; } export interface DirectorMetadata extends BaseAgentMetadata { diff --git a/apps/smithy-web/src/components/agent/AgentCard.tsx b/apps/smithy-web/src/components/agent/AgentCard.tsx index e5e7e29c..972548be 100644 --- a/apps/smithy-web/src/components/agent/AgentCard.tsx +++ b/apps/smithy-web/src/components/agent/AgentCard.tsx @@ -5,12 +5,13 @@ */ import { useState, useRef, useEffect } from 'react'; -import { Play, Square, RefreshCw, Terminal, MoreVertical, Clock, GitBranch, Pencil, Inbox, Trash2, ArrowLeftRight, Settings, Zap } from 'lucide-react'; +import { Play, Square, RefreshCw, Terminal, MoreVertical, Clock, GitBranch, Pencil, Inbox, Trash2, ArrowLeftRight, Settings, Zap, Power, PowerOff } from 'lucide-react'; import type { Agent, WorkerMetadata, StewardMetadata, SessionStatus } from '../../api/types'; import { AgentStatusBadge } from './AgentStatusBadge'; import { AgentRoleBadge } from './AgentRoleBadge'; import { Tooltip } from '@stoneforge/ui'; import { useAgentInboxCount } from '../../api/hooks/useAgentInbox'; +import { useToggleAgentDisabled } from '../../api/hooks/useAgents'; import { AgentInboxDrawer } from './AgentInboxDrawer'; import { ChangeProviderDialog } from './ChangeProviderDialog'; import { getProviderLabel } from '../../lib/providers'; @@ -55,8 +56,10 @@ export function AgentCard({ const agentMeta = agent.metadata?.agent; const isRunning = activeSessionStatus === 'running' || activeSessionStatus === 'starting'; - const canStart = !isRunning && !isStarting; + const isDisabled = agentMeta?.disabled === true; + const canStart = !isRunning && !isStarting && !isDisabled; const canStop = isRunning && !isStopping; + const toggleDisabled = useToggleAgentDisabled(); // Close menu when clicking outside useEffect(() => { @@ -78,8 +81,9 @@ export function AgentCard({ return (
{/* Header */}
@@ -118,6 +122,15 @@ export function AgentCard({ {agentMeta.model} )} + {isDisabled && ( + + + Disabled + + )}
{agentMeta?.executablePath && (
)} +