Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/agent-disable.md
Original file line number Diff line number Diff line change
@@ -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 <id>` and `sf agent enable <id>`.
- `sf agent list` appends `(disabled)` to the status column when set.
- `sf agent start <id>` 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.
22 changes: 22 additions & 0 deletions apps/docs/src/content/docs/reference/cli-orchestration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ sf agent list [options]
| `--reportsTo <id>` | 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`
Expand Down Expand Up @@ -97,6 +99,26 @@ sf agent stop <id> [--no-graceful] [--reason <text>]
| `--no-graceful` | Force immediate shutdown |
| `-r, --reason <text>` | Reason for stopping |

#### `sf agent disable`

```bash
sf agent disable <id>
```

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 <id>`.

#### `sf agent enable`

```bash
sf agent enable <id>
```

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
Expand Down
40 changes: 40 additions & 0 deletions apps/docs/src/content/docs/reference/orchestrator-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions apps/smithy-web/src/api/hooks/useAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentResponse, Error, { agentId: string; disabled: boolean }>({
mutationFn: async ({ agentId, disabled }) => {
return fetchApi<AgentResponse>(`/agents/${agentId}`, {
method: 'PATCH',
body: JSON.stringify({ disabled }),
});
},
onSuccess: (_, { agentId }) => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
queryClient.invalidateQueries({ queryKey: ['agent', agentId] });
},
});
}

/**
* Hook to delete an agent
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/smithy-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 35 additions & 3 deletions apps/smithy-web/src/components/agent/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand All @@ -78,8 +81,9 @@ export function AgentCard({

return (
<div
className="p-4 border border-[var(--color-border)] rounded-lg bg-[var(--color-surface)] hover:border-[var(--color-border-hover)] transition-colors duration-150"
className={`p-4 border border-[var(--color-border)] rounded-lg bg-[var(--color-surface)] hover:border-[var(--color-border-hover)] transition-colors duration-150${isDisabled ? ' opacity-60' : ''}`}
data-testid={`agent-card-${agent.id}`}
data-disabled={isDisabled || undefined}
>
{/* Header */}
<div className="flex items-start justify-between gap-3">
Expand Down Expand Up @@ -118,6 +122,15 @@ export function AgentCard({
{agentMeta.model}
</span>
)}
{isDisabled && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-warning-muted)] text-[var(--color-warning-text)] border border-[var(--color-warning)]"
data-testid={`agent-disabled-badge-${agent.id}`}
>
<PowerOff className="w-3 h-3" />
Disabled
</span>
)}
</div>
{agentMeta?.executablePath && (
<div
Expand Down Expand Up @@ -219,6 +232,25 @@ export function AgentCard({
Change triggers
</button>
)}
<button
onClick={() => {
setMenuOpen(false);
toggleDisabled.mutate({ agentId: agent.id, disabled: !isDisabled });
}}
disabled={toggleDisabled.isPending}
className="
w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm
text-[var(--color-text-secondary)]
hover:bg-[var(--color-surface-hover)]
hover:text-[var(--color-text)]
whitespace-nowrap
disabled:opacity-50 disabled:cursor-not-allowed
"
data-testid={`agent-toggle-disabled-${agent.id}`}
>
{isDisabled ? <Power className="w-3.5 h-3.5" /> : <PowerOff className="w-3.5 h-3.5" />}
{isDisabled ? 'Enable agent' : 'Disable agent'}
</button>
<button
onClick={() => {
setMenuOpen(false);
Expand Down
15 changes: 9 additions & 6 deletions apps/smithy-web/src/components/layout/DirectorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -954,11 +954,13 @@ export function DirectorPanel({ collapsed = false, onToggle, isMaximized = false
</Tooltip>

{/* Session Controls */}
{!activeInfo.hasActiveSession ? (
<Tooltip content="Start Session" side="bottom">
{(() => {
const activeIsDisabled = activeInfo.director.metadata?.agent?.disabled === true;
return !activeInfo.hasActiveSession ? (
<Tooltip content={activeIsDisabled ? 'Director is disabled. Enable it before starting a session.' : 'Start Session'} side="bottom">
<button
onClick={handleStartActiveSession}
disabled={startSessionMutation.isPending}
disabled={startSessionMutation.isPending || activeIsDisabled}
className="p-1 rounded-md text-[var(--color-success)] hover:bg-[var(--color-surface-hover)] transition-colors duration-150 disabled:opacity-50"
aria-label="Start Session"
data-testid={`director-start-${activeDirectorId}`}
Expand All @@ -972,10 +974,10 @@ export function DirectorPanel({ collapsed = false, onToggle, isMaximized = false
</Tooltip>
) : (
<>
<Tooltip content="Restart Session" side="bottom">
<Tooltip content={activeIsDisabled ? 'Director is disabled. Enable it before restarting.' : 'Restart Session'} side="bottom">
<button
onClick={handleRestartActiveSession}
disabled={stopSessionMutation.isPending || startSessionMutation.isPending}
disabled={stopSessionMutation.isPending || startSessionMutation.isPending || activeIsDisabled}
className="p-1 rounded-md text-[var(--color-warning)] hover:bg-[var(--color-surface-hover)] transition-colors duration-150 disabled:opacity-50"
aria-label="Restart Session"
data-testid={`director-restart-${activeDirectorId}`}
Expand Down Expand Up @@ -1003,7 +1005,8 @@ export function DirectorPanel({ collapsed = false, onToggle, isMaximized = false
</button>
</Tooltip>
</>
)}
);
})()}
</>
);
})()}
Expand Down
7 changes: 5 additions & 2 deletions apps/smithy-web/src/components/layout/DirectorTabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface DirectorTabContentProps {
export function DirectorTabContent({ info, isVisible, showMessagesQueue, onTerminalReady }: DirectorTabContentProps) {
const { director, hasActiveSession, hasResumableSession, error } = info;
const lastResumableSession = info.lastResumableSession as { providerSessionId?: string } | null;
const isDirectorDisabled = director.metadata?.agent?.disabled === true;

const [terminalStatus, setTerminalStatus] = useState<TerminalStatus>('disconnected');
const [resumeError, setResumeError] = useState<string | null>(null);
Expand Down Expand Up @@ -231,7 +232,8 @@ export function DirectorTabContent({ info, isVisible, showMessagesQueue, onTermi
<div className="flex flex-col items-center gap-3">
<button
onClick={handleResumeSession}
disabled={resumeSession.isPending || startSession.isPending}
disabled={resumeSession.isPending || startSession.isPending || isDirectorDisabled}
title={isDirectorDisabled ? 'Director is disabled. Enable it before resuming a session.' : undefined}
className="
inline-flex items-center gap-2
px-5 py-2 rounded-lg
Expand Down Expand Up @@ -261,7 +263,8 @@ export function DirectorTabContent({ info, isVisible, showMessagesQueue, onTermi

<button
onClick={handleStartSession}
disabled={startSession.isPending || resumeSession.isPending}
disabled={startSession.isPending || resumeSession.isPending || isDirectorDisabled}
title={isDirectorDisabled ? 'Director is disabled. Enable it before starting a session.' : undefined}
className="
inline-flex items-center gap-2
px-4 py-1.5 rounded-md
Expand Down
11 changes: 7 additions & 4 deletions apps/smithy-web/src/components/workspace/WorkspacePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { StreamViewer } from './StreamViewer';
import { TerminalInput } from './TerminalInput';
import { Tooltip } from '@stoneforge/ui';
import { SessionHistoryModal } from './SessionHistoryModal';
import { useAgentStatus, useStartAgentSession, useStopAgentSession, useInterruptAgentSession, useResumeAgentSession } from '../../api/hooks/useAgents';
import { useAgent, useAgentStatus, useStartAgentSession, useStopAgentSession, useInterruptAgentSession, useResumeAgentSession } from '../../api/hooks/useAgents';
import { useAgentTokens, formatTokenCount } from '../../api/hooks/useAgentTokens';

export interface WorkspacePaneProps {
Expand Down Expand Up @@ -92,6 +92,8 @@ export const WorkspacePane = forwardRef<WorkspacePaneHandle, WorkspacePaneProps>

// Session status and controls for workers (not director - that has its own panel)
const { data: statusData } = useAgentStatus(pane.agentRole !== 'director' ? pane.agentId : undefined);
const { data: agentData } = useAgent(pane.agentId);
const isAgentDisabled = agentData?.agent?.metadata?.agent?.disabled === true;
const startSession = useStartAgentSession();
const stopSession = useStopAgentSession();
const interruptSession = useInterruptAgentSession();
Expand Down Expand Up @@ -337,15 +339,15 @@ export const WorkspacePane = forwardRef<WorkspacePaneHandle, WorkspacePaneProps>
e.stopPropagation();
handleStartSession();
}}
disabled={startSession.isPending}
disabled={startSession.isPending || isAgentDisabled}
className="
p-1 rounded
text-green-600 dark:text-green-400
hover:bg-[var(--color-surface-hover)]
transition-colors
disabled:opacity-50
"
title="Start Session"
title={isAgentDisabled ? 'Agent is disabled. Enable it before starting a session.' : 'Start Session'}
data-testid="pane-start-session"
>
{startSession.isPending ? (
Expand Down Expand Up @@ -694,7 +696,8 @@ export const WorkspacePane = forwardRef<WorkspacePaneHandle, WorkspacePaneProps>
{/* Start button */}
<button
onClick={handleStartSession}
disabled={startSession.isPending}
disabled={startSession.isPending || isAgentDisabled}
title={isAgentDisabled ? 'Agent is disabled. Enable it before starting a session.' : undefined}
className="
inline-flex items-center gap-2 flex-shrink-0
px-4 py-2 rounded-lg
Expand Down
12 changes: 10 additions & 2 deletions packages/smithy/src/api/orchestrator-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ export interface OrchestratorAPI extends QuarryAPI {
markAsStarted?: boolean;
}
): Promise<Task>;

/**
* Updates an agent's metadata (merges with existing metadata)
*/
updateAgentMetadata(
agentId: EntityId,
updates: Partial<AgentMetadata>
): Promise<AgentEntity>;
}

// ============================================================================
Expand Down Expand Up @@ -629,9 +637,9 @@ export class OrchestratorAPIImpl extends QuarryAPIImpl implements OrchestratorAP
}

/**
* Updates an agent's metadata
* Updates an agent's metadata (merges with existing metadata)
*/
private async updateAgentMetadata(
async updateAgentMetadata(
agentId: EntityId,
updates: Partial<AgentMetadata>
): Promise<AgentEntity> {
Expand Down
Loading
Loading