Skip to content
Draft
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
132 changes: 132 additions & 0 deletions skills/dynamic-workflows/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
name: dynamic-workflows
description: Orchestrate multi-agent coding workflows via DevSpace Dynamic Workflows (CLI or MCP).
---

# Dynamic Workflows

Use this skill when the user wants multi-step, multi-agent orchestration — fan-out
review, migrate-and-verify, research panels — **not** a single subagent turn.

## Entry points

| Host | Surface |
|---|---|
| Coding agent (Claude Code, Codex, pi, …) | CLI + this skill |
| ChatGPT / MCP client | MCP tools `run_workflow` / `workflow_status` / `workflow_cancel` |

```bash
devspace workflow run --file path/to/script.js [--arg k=v]... [--follow]
devspace workflow run --name review-auth [--follow]
devspace workflow run --resume <runId>
devspace workflow status <runId> [--follow]
devspace workflow cancel <runId>
devspace workflow ls
```

Named scripts: `.devspace/workflows/<name>.js` or `workflows/<name>.js`.

## Script shape

```js
export const meta = {
name: 'review-auth',
description: 'Fan-out review of auth changes',
phases: [{ title: 'Review' }, { title: 'Synthesize' }],
// optional DevSpace:
// defaultProvider: 'codex',
// concurrency: 4,
}

phase('Review')
const findings = await parallel([
() => agent('Review for correctness…', { label: 'correctness' }),
() => agent('Review for security…', { label: 'security' }),
])
phase('Synthesize')
const summary = await agent(`Synthesize: ${JSON.stringify(findings)}`)
return { summary, findings }
```

### Primitives

| API | Notes |
|---|---|
| `agent(prompt, opts?)` | Throws on failure. `opts`: `label`, `phase`, `schema`, `model`, `effort`, `provider`, `isolation: 'worktree'` |
| `parallel(thunks)` | Barrier; throw → `null` slot |
| `pipeline(items, ...stages)` | Per-item chains; no cross-item barrier |
| `phase(title)` / `log(msg)` | Progress; journaled |
| `args` | Run input (object preferred) |
| `budget` | Stub: `total: null`, `remaining(): Infinity` — do not loop on budget alone |
| `workflow(name\|{scriptPath}, args?)` | Nested, depth 1, shared call index |

**No `writeMode`.** Teach read-only vs write in the prompt. Use `isolation: 'worktree'` when parallel mutators would conflict (git required).

### Determinism bans

`Date.now()`, `Math.random()`, and `new Date()` without args throw. Pass timestamps via `args` if needed.

### Schema

```js
const out = await agent('Return JSON findings', {
schema: {
type: 'object',
properties: { bugs: { type: 'array', items: { type: 'string' } } },
required: ['bugs'],
},
})
// out is validated object; engine retries ≤2 on invalid JSON
```

### Providers

Default: first **enabled ∩ available** provider (`agentProviders.enabled` in config, else all live providers in product order). Override with `opts.provider` or `meta.defaultProvider`.

### Resume

`devspace workflow run --resume <runId>` creates a **new** run that replays completed agent calls by cache key (callIndex+key, then consume-once by key).

### Cancel

`workflow cancel` sets a cooperative flag; worker aborts then hard-kills if needed.

## When to use CLI vs MCP

- **CLI**: host agent can shell; prefer for long runs + `--follow`.
- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. Disconnecting MCP does **not** kill the worker.

## Worked mini-examples

**1. Parallel review**

```js
export const meta = { name: 'p-review', description: 'Two reviewers' }
const [a, b] = await parallel([
() => agent('Correctness review of the diff', { label: 'corr' }),
() => agent('Security review of the diff', { label: 'sec' }),
])
return { a, b }
```

**2. Pipeline with schema**

```js
export const meta = { name: 'pipe', description: 'Find then fix plan' }
return await pipeline(
args.files,
(file) => agent(`List bugs in ${file}`, { schema: { type: 'object', properties: { bugs: { type: 'array', items: { type: 'string' } } }, required: ['bugs'] } }),
(findings, file) => agent(`Plan fixes for ${file}: ${JSON.stringify(findings)}`),
)
```

**3. Isolation for parallel writers**

```js
export const meta = { name: 'iso', description: 'Parallel mutators' }
await parallel([
() => agent('Implement feature A in isolation', { isolation: 'worktree', label: 'a' }),
() => agent('Implement feature B in isolation', { isolation: 'worktree', label: 'b' }),
])
// dirty worktrees preserved; compose via return text / shared follow-up
```
70 changes: 69 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import { runLocalAgentProvider } from "./local-agent-adapters.js";
import {
isLocalAgentProvider,
loadLocalAgentProfiles,
LOCAL_AGENT_PROVIDERS,
type LocalAgentProfile,
} from "./local-agent-profiles.js";
import {
assertLocalAgentProviderAvailable,
formatLocalAgentProviderAvailabilitySummary,
getLocalAgentProviderAvailabilitySnapshot,
} from "./local-agent-availability.js";
import {
formatAvailableLocalAgentTargets,
Expand Down Expand Up @@ -169,12 +171,18 @@ async function runInit({ force }: { force: boolean }): Promise<void> {
validate: validateRequiredPublicBaseUrl,
}));

const subagents = resolveSubagentsFlag(files.config);
const agentProviders =
subagents === true
? probeAndBuildAgentProviders(files.config.agentProviders)
: files.config.agentProviders;
const config: DevspaceUserConfig = {
host: files.config.host ?? "127.0.0.1",
port,
allowedRoots,
publicBaseUrl,
subagents: resolveSubagentsFlag(files.config),
subagents,
...(agentProviders ? { agentProviders } : {}),
};
const auth = {
ownerToken: files.auth.ownerToken ?? generateOwnerToken(),
Expand Down Expand Up @@ -277,11 +285,71 @@ async function runDoctor(): Promise<void> {
console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`);
console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`);
console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`);
console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`);
if (config.subagents) {
const snapshot = getLocalAgentProviderAvailabilitySnapshot();
console.log(
`Agent providers (live): ${formatLocalAgentProviderAvailabilitySummary(snapshot)}`,
);
if (config.agentProviders) {
console.log(
`Agent providers (enabled): ${
config.agentProviders.enabled.length
? config.agentProviders.enabled.join(", ")
: "(empty — no providers)"
}`,
);
if (config.agentProviders.detectedAt) {
console.log(`Agent providers last probe: ${config.agentProviders.detectedAt}`);
}
} else {
console.log("Agent providers (config): missing (compat = all available)");
}

// Refresh lastProbe write-back when subagents on and config exists
if (files.configExists) {
const refreshed = probeAndBuildAgentProviders(files.config.agentProviders);
writeDevspaceConfig({
...files.config,
agentProviders: {
// keep user enable-list if set; only refresh probe metadata + available adds when empty
enabled:
files.config.agentProviders?.enabled ?? refreshed.enabled,
detectedAt: refreshed.detectedAt,
lastProbe: refreshed.lastProbe,
},
});
console.log(`Agent providers probe written to ${files.configPath}`);
}
}
} catch (error) {
console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`);
}
}

/** Probe PATH and build AgentProvidersConfig (available ids in product order). */
function probeAndBuildAgentProviders(
existing?: DevspaceUserConfig["agentProviders"],
): NonNullable<DevspaceUserConfig["agentProviders"]> {
const snapshot = getLocalAgentProviderAvailabilitySnapshot();
const available = new Set(
snapshot.filter((row) => row.available).map((row) => row.name),
);
const enabled =
existing?.enabled && existing.enabled.length > 0
? existing.enabled.filter((id) => LOCAL_AGENT_PROVIDERS.includes(id as never))
: LOCAL_AGENT_PROVIDERS.filter((id) => available.has(id));
return {
enabled,
detectedAt: new Date().toISOString(),
lastProbe: snapshot.map((row) => ({
id: row.name,
available: row.available,
detail: row.reason,
})),
};
}

function runConfigCommand(args: string[]): void {
const [subcommand, key, ...rest] = args;
const files = loadDevspaceFiles();
Expand Down
7 changes: 6 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ assert.equal(resolveSubagentsFlag({}, { DEVSPACE_SUBAGENTS: "1" }), true);

const seededConfigDir = mkdtempSync(join(tmpdir(), "devspace-seeded-skills-test-"));
const seededSkillPaths = ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir });
assert.deepEqual(seededSkillPaths, [join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md")]);
assert.deepEqual(seededSkillPaths, [
join(seededConfigDir, "skills", "subagent-delegation", "SKILL.md"),
join(seededConfigDir, "skills", "dynamic-workflows", "SKILL.md"),
]);
assert.equal(existsSync(seededSkillPaths[0]), true);
assert.equal(existsSync(seededSkillPaths[1]), true);
assert.match(readFileSync(seededSkillPaths[0], "utf8"), /name: subagent-delegation/);
assert.match(readFileSync(seededSkillPaths[1], "utf8"), /name: dynamic-workflows/);
assert.deepEqual(ensureDevspaceDefaultSkills({ DEVSPACE_CONFIG_DIR: seededConfigDir }), []);

assert.throws(
Expand Down
47 changes: 47 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { expandHomePath } from "./roots.js";
import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js";
import type { OAuthConfig } from "./oauth-provider.js";
import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js";
import type { AgentProvidersConfig } from "./workflow-types.js";
import {
isLocalAgentProvider,
LOCAL_AGENT_PROVIDERS,
type LocalAgentProvider,
} from "./local-agent-profiles.js";

export type ToolMode = "minimal" | "full" | "codex";
export type WidgetMode = "off" | "changes" | "full";
Expand All @@ -26,6 +32,12 @@ export interface ServerConfig {
devspaceSkillsDir: string;
devspaceAgentsDir: string;
subagents: boolean;
/**
* Resolved enable-list for agent providers.
* Missing user config → undefined (compat: all live providers).
* Explicit empty → no providers.
*/
agentProviders?: AgentProvidersConfig;
agentDir: string;
logging: LoggingConfig;
}
Expand Down Expand Up @@ -234,11 +246,46 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
env.DEVSPACE_SUBAGENTS === undefined
? files.config.subagents === true
: parseBoolean(env.DEVSPACE_SUBAGENTS),
agentProviders: parseAgentProvidersConfig(
env.DEVSPACE_AGENT_PROVIDERS,
files.config.agentProviders,
),
agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())),
logging: parseLoggingConfig(env),
};
}

/**
* Env `DEVSPACE_AGENT_PROVIDERS=codex,claude` replaces enabled list.
* Missing config → undefined (compat all-available).
* Explicit enabled: [] stays empty.
*/
export function parseAgentProvidersConfig(
envValue: string | undefined,
fileConfig: AgentProvidersConfig | undefined,
): AgentProvidersConfig | undefined {
if (envValue !== undefined) {
const enabled = envValue
.split(",")
.map((entry) => entry.trim())
.filter((entry): entry is LocalAgentProvider => isLocalAgentProvider(entry));
return { enabled };
}
if (!fileConfig) return undefined;
const enabled = (fileConfig.enabled ?? []).filter((id): id is LocalAgentProvider =>
isLocalAgentProvider(id),
);
return {
enabled,
detectedAt: fileConfig.detectedAt,
lastProbe: fileConfig.lastProbe,
};
}

export function defaultAgentProvidersOrder(): LocalAgentProvider[] {
return [...LOCAL_AGENT_PROVIDERS];
}

function parsePublicBaseUrl(value: string): string {
const parsed = new URL(value);
parsed.hash = "";
Expand Down
5 changes: 5 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { formatPathForPrompt } from "./skills.js";
import { createWorkspaceStore } from "./workspace-store.js";
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
import { registerWorkflowTools } from "./workflow-tools.js";
import {
formatLocalAgentProviderAvailabilitySummary,
getLocalAgentProviderAvailabilitySnapshot,
Expand Down Expand Up @@ -1594,6 +1595,10 @@ function createMcpServer(
registerCodexProcessTools(server, config, workspaces, processSessions);
}

if (config.subagents) {
registerWorkflowTools(server, config, workspaces);
}

return server;
}

Expand Down
25 changes: 14 additions & 11 deletions src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,25 @@ export interface SkillReadResolution {
}

const SUBAGENT_DELEGATION_NAME = "subagent-delegation";
const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md");
const DYNAMIC_WORKFLOWS_NAME = "dynamic-workflows";

function bundledSkillsDir(): string {
return fileURLToPath(new URL("../skills", import.meta.url));
}

function hasSubagentDelegationSkill(skillDir: string): boolean {
return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL));
}

/**
* Always include the bundled skills root when subagents are enabled.
* Previously the whole dir was dropped if the user had seeded
* subagent-delegation — that hid later skills (e.g. dynamic-workflows).
* User/devspace copies still win via earlier path order + name collisions.
*/
export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] {
const bundledSkills = bundledSkillsDir();
const defaultPathCandidates = [
join(homedir(), ".agents", "skills"),
resolve(cwd, ".agents", "skills"),
config.devspaceSkillsDir,
join(config.agentDir, "skills"),
config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir)
? bundledSkills
: undefined,
config.subagents ? bundledSkillsDir() : undefined,
];
const defaultPaths = defaultPathCandidates.filter(
(path): path is string => path !== undefined && existsSync(path),
Expand Down Expand Up @@ -73,11 +72,15 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk

if (config.subagents) return result;

const gated = new Set<string>([SUBAGENT_DELEGATION_NAME, DYNAMIC_WORKFLOWS_NAME]);
return {
skills: result.skills.filter((skill) => skill.name !== SUBAGENT_DELEGATION_NAME),
skills: result.skills.filter((skill) => !gated.has(skill.name)),
diagnostics: result.diagnostics.filter((diagnostic) => {
const collision = diagnostic.collision;
return !(collision?.resourceType === "skill" && collision.name === SUBAGENT_DELEGATION_NAME);
return !(
collision?.resourceType === "skill" &&
gated.has(collision.name)
);
}),
};
}
Expand Down
Loading
Loading