Skip to content

Commit 0b6099e

Browse files
author
“646826”
committed
feat(agents): bulk edit agent runtime, model, concurrency, args, and env
Add a Bulk edit dialog so workspace owners/admins can update shared launch settings across selected agents or every active agent in a workspace — runtime, model, max concurrent tasks, custom CLI args, and environment variable keys — with each field opt-in (disabled fields are left unchanged). Single-agent and bulk "clear model" both persist SQL NULL, falling back to the runtime/CLI default. Changing runtime/model only affects new tasks; Multica keeps no workspace-level active engine. DB / API / server: - agent.sql + generated: ListAgentsForUpdate, ListAgentsByIDsForUpdate (write), ListAgentsByIDs (read, non-locking — no per-agent N+1), ClearAgentModel. - handler: BulkUpdateAgents + ListBulkAgentEnvKeys (owner/admin only, agent actor rejected 403, one transaction across all targets, env-change audit by key name only). Env values are never returned, logged, or surfaced — only key names and counts. patchAgentEnv preserves a value via a sentinel without reading it. Core: - typed API client (parseWithFallback + zod, typed EMPTY_* fallbacks) and a local presets store via StorageAdapter (env values never persisted). UI: - bulk-edit dialog + agents-page entry points; affected-count banner uses the semantic warning token; all copy via i18n in en/ja/ko/zh-Hans. Docs: - agents-runtime-model.mdx + nav. Tests: - handler coverage (selected/all, clear-model->NULL, archived rejection, UUID dedup, agent-actor 403, env keys-only / no value leak), core/views vitest, e2e/agents-bulk-edit.spec.ts. Shared e2e infra hardened for per-worker isolation and onboarding-v2.
1 parent 18a58e8 commit 0b6099e

29 files changed

Lines changed: 4697 additions & 52 deletions

apps/docs/content/docs/agents-create.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Most AI coding tools support model selection (for example, Claude Code lets you
5252

5353
Changing the model **only applies to new tasks**. Already-dispatched tasks continue with the model that was locked in at dispatch time.
5454

55+
To update runtime, model, concurrency, CLI args, or environment keys across selected agents or every active agent, see [Bulk edit agents](/agents-runtime-model).
56+
5557
## Custom environment variables (custom_env)
5658

5759
**Custom environment variables** (`custom_env`) let you inject extra env vars at task execution time — typical uses are API keys or switching the upstream endpoint:
@@ -123,5 +125,6 @@ Archived agents can't be assigned new tasks.
123125
## Next steps
124126

125127
- [Skills](/skills) — attach knowledge packs to an agent
128+
- [Bulk edit agents](/agents-runtime-model) — update runtime, model, concurrency, CLI args, or environment keys across agents
126129
- [AI coding tools comparison](/providers) — full capability matrix across all 12 tools
127130
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
title: Bulk edit agents
3+
description: Update runtime, model, concurrency, CLI args, or environment keys for selected agents or every active agent in a workspace.
4+
---
5+
6+
import { Callout } from "fumadocs-ui/components/callout";
7+
8+
Bulk edit lets workspace owners and admins update shared launch settings across selected agents, or across every active agent in a workspace.
9+
10+
Each field is opt-in. Disabled fields are left unchanged, so you can update only the model, only CLI args, only environment keys, or any supported combination.
11+
12+
## What you can change
13+
14+
| Field | Behavior |
15+
|---|---|
16+
| Runtime | Moves targeted agents to another runtime in the same workspace |
17+
| Model | Pins a model, or clears the override when left empty |
18+
| Max concurrent tasks | Replaces the per-agent concurrency limit |
19+
| Custom args | Adds, replaces, or removes exact CLI argument tokens |
20+
| Environment variables | Sets/replaces key values or removes keys without reading existing values |
21+
22+
An agent's runtime decides which AI coding tool runs its tasks. The optional **model** field is passed to that tool when it supports per-agent model selection.
23+
24+
Multica does not store a workspace-wide active provider. Each agent points to a runtime, and the selected runtime's provider is used for new tasks.
25+
26+
Changing runtime or model only affects **new tasks**. Tasks that were already claimed or running continue with the runtime and model resolved when they started.
27+
28+
<Callout type="warning">
29+
Bulk edit changes agent launch configuration. It does not change retry, failover, billing, token refresh, or account-management behavior.
30+
</Callout>
31+
32+
## Bulk edit selected agents
33+
34+
1. Go to the **Agents** page.
35+
2. Select the active agents you want to update.
36+
3. Click **Bulk edit selected**.
37+
4. Enable only the fields you want to change.
38+
5. Review the affected-agent count.
39+
6. Click **Apply**.
40+
41+
Selected archived agents are rejected by the server. If an agent is archived before the update is applied, the server refuses that target instead of changing an archived agent.
42+
43+
## Bulk edit all active agents
44+
45+
Workspace owners and admins can update every active agent at once:
46+
47+
1. Go to the **Agents** page.
48+
2. Click **Bulk edit agents** in the header.
49+
3. Enable only the fields you want to change.
50+
4. Enter the new values or operations.
51+
5. Review the affected-agent count.
52+
6. Click **Apply**.
53+
54+
This updates every **non-archived** agent in the workspace. Archived agents are left unchanged.
55+
56+
<Callout type="info">
57+
The count shown before applying is the current number of active agents in the UI. The success toast uses the server-returned update count, which is the authoritative number of agents changed.
58+
</Callout>
59+
60+
## Runtime and model
61+
62+
When you update runtime or model, Multica stores:
63+
64+
| Field | Meaning |
65+
|---|---|
66+
| `runtime_id` | The runtime that will claim new tasks for the agent |
67+
| `runtime_mode` | The runtime mode copied from the selected runtime |
68+
| `model` | The optional model to pass to that runtime's provider |
69+
70+
`runtime_id` is the provider source for new tasks. The model is optional; leaving it empty lets the runtime or CLI choose its default.
71+
72+
The target runtime must belong to the workspace. Workspace owners and admins can use any workspace runtime in the bulk action.
73+
74+
## Custom args
75+
76+
Custom arg operations apply to each targeted agent's existing `custom_args`:
77+
78+
- **Add** appends the arg when it is not already present.
79+
- **Replace** changes exact matching arg tokens.
80+
- **Remove** deletes exact matching arg tokens.
81+
82+
Multi-token input is split the same way as the agent **Custom Args** tab. For example, `--max-turns 100` becomes two args: `--max-turns` and `100`.
83+
84+
## Environment keys
85+
86+
The bulk dialog can suggest existing environment keys, but it never loads existing values. This keeps secret values out of the bulk UI.
87+
88+
- **Set / replace** adds a missing key or replaces an existing value.
89+
- **Remove** deletes a key from agents where it exists.
90+
- If the same key appears more than once in the form, the last operation wins.
91+
92+
Local presets can remember field choices, runtime, model, custom arg operations, and environment key operations for the current browser and workspace. Presets do **not** save environment values; values must be entered again after loading a preset.
93+
94+
## Permissions
95+
96+
Bulk edit is available to workspace owners and admins. Individual agent updates still follow the normal agent-management rules: workspace owners and admins can manage any agent, and an agent owner can manage their own agent.
97+
98+
## Next
99+
100+
- [Create and configure an agent](/agents-create) — all agent settings
101+
- [AI coding tools matrix](/providers) — provider capabilities and model-selection differences

apps/docs/content/docs/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"---Agents---",
1616
"agents",
1717
"agents-create",
18+
"agents-runtime-model",
1819
"skills",
1920
"squads",
2021
"---How agents run---",
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
3+
import { setCurrentWorkspace } from "../../platform/workspace-storage";
4+
import { useAgentBulkEditPresetsStore } from "./bulk-edit-presets-store";
5+
6+
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
7+
8+
beforeAll(() => {
9+
if (typeof globalThis.localStorage?.clear !== "function") {
10+
const values = new Map<string, string>();
11+
const storage: Storage = {
12+
get length() { return values.size; },
13+
clear: () => values.clear(),
14+
getItem: (k) => values.get(k) ?? null,
15+
key: (i) => Array.from(values.keys())[i] ?? null,
16+
removeItem: (k) => { values.delete(k); },
17+
setItem: (k, v) => { values.set(k, v); },
18+
};
19+
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
20+
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
21+
}
22+
});
23+
24+
beforeEach(() => {
25+
localStorage.clear();
26+
setCurrentWorkspace(null, null);
27+
useAgentBulkEditPresetsStore.setState({ presets: [] });
28+
});
29+
30+
afterEach(() => {
31+
setCurrentWorkspace(null, null);
32+
});
33+
34+
describe("useAgentBulkEditPresetsStore", () => {
35+
it("persists bulk edit presets under the workspace namespace", async () => {
36+
setCurrentWorkspace("acme", "ws_a");
37+
await flush();
38+
39+
useAgentBulkEditPresetsStore.getState().savePreset("Claude local", {
40+
model: "claude-sonnet-4",
41+
customArgsPatch: [
42+
{ action: "add", value: "--permission-mode" },
43+
{ action: "add", value: "acceptEdits" },
44+
],
45+
env: [{ action: "remove", key: "OLD_KEY" }],
46+
});
47+
48+
const raw = localStorage.getItem("multica_agent_bulk_edit_presets:acme");
49+
expect(raw).not.toBeNull();
50+
expect(JSON.parse(raw as string).state.presets).toHaveLength(1);
51+
});
52+
53+
it("does not persist env values in local presets", async () => {
54+
setCurrentWorkspace("acme", "ws_a");
55+
await flush();
56+
57+
useAgentBulkEditPresetsStore.getState().savePreset("Secret skeleton", {
58+
env: [
59+
{ action: "set", key: "ANTHROPIC_API_KEY", value: "sk-secret" },
60+
{ action: "remove", key: "OLD_KEY" },
61+
] as never,
62+
});
63+
64+
const preset = useAgentBulkEditPresetsStore.getState().presets[0]!;
65+
expect(preset.patch.env).toEqual([
66+
{ action: "set", key: "ANTHROPIC_API_KEY" },
67+
{ action: "remove", key: "OLD_KEY" },
68+
]);
69+
expect(localStorage.getItem("multica_agent_bulk_edit_presets:acme")).not.toContain("sk-secret");
70+
});
71+
72+
it("sanitizes custom arg and env operations before persisting a preset", async () => {
73+
setCurrentWorkspace("acme", "ws_a");
74+
await flush();
75+
76+
useAgentBulkEditPresetsStore.getState().savePreset(" Clean preset ", {
77+
customArgsPatch: [
78+
{ action: "add", value: " --verbose " },
79+
{ action: "replace", value: " --old ", replacement: " --new " },
80+
{ action: "replace", value: "--missing-replacement", replacement: " " },
81+
{ action: "remove", value: " " },
82+
],
83+
env: [
84+
{ action: "set", key: " API_KEY " },
85+
{ action: "remove", key: " OLD_KEY " },
86+
{ action: "set", key: " " },
87+
],
88+
} as never);
89+
90+
const preset = useAgentBulkEditPresetsStore.getState().presets[0]!;
91+
expect(preset.name).toBe("Clean preset");
92+
expect(preset.patch.customArgsPatch).toEqual([
93+
{ action: "add", value: "--verbose", replacement: undefined },
94+
{ action: "replace", value: "--old", replacement: "--new" },
95+
]);
96+
expect(preset.patch.env).toEqual([
97+
{ action: "set", key: "API_KEY" },
98+
{ action: "remove", key: "OLD_KEY" },
99+
]);
100+
});
101+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"use client";
2+
3+
import { create } from "zustand";
4+
import { createJSONStorage, persist } from "zustand/middleware";
5+
import { createSafeId } from "../../utils";
6+
import {
7+
createWorkspaceAwareStorage,
8+
registerForWorkspaceRehydration,
9+
} from "../../platform/workspace-storage";
10+
import { defaultStorage } from "../../platform/storage";
11+
12+
export interface AgentBulkEditPresetEnvOperation {
13+
action: "set" | "remove";
14+
key: string;
15+
}
16+
17+
export interface AgentBulkEditPresetCustomArgOperation {
18+
action: "add" | "replace" | "remove";
19+
value: string;
20+
replacement?: string;
21+
}
22+
23+
export interface AgentBulkEditPresetPatch {
24+
runtimeId?: string;
25+
model?: string;
26+
maxConcurrentTasks?: number;
27+
customArgsPatch?: AgentBulkEditPresetCustomArgOperation[];
28+
env?: AgentBulkEditPresetEnvOperation[];
29+
}
30+
31+
export interface AgentBulkEditPreset {
32+
id: string;
33+
name: string;
34+
patch: AgentBulkEditPresetPatch;
35+
updatedAt: number;
36+
}
37+
38+
interface AgentBulkEditPresetsState {
39+
presets: AgentBulkEditPreset[];
40+
savePreset: (name: string, patch: AgentBulkEditPresetPatch) => string;
41+
removePreset: (id: string) => void;
42+
}
43+
44+
const MAX_PRESETS = 12;
45+
46+
export const useAgentBulkEditPresetsStore = create<AgentBulkEditPresetsState>()(
47+
persist(
48+
(set) => ({
49+
presets: [],
50+
savePreset: (name, patch) => {
51+
const id = createSafeId();
52+
const preset: AgentBulkEditPreset = {
53+
id,
54+
name: name.trim(),
55+
patch: sanitizePresetPatch(patch),
56+
updatedAt: Date.now(),
57+
};
58+
set((state) => ({
59+
presets: [
60+
preset,
61+
...state.presets.filter((existing) => existing.name !== preset.name),
62+
].slice(0, MAX_PRESETS),
63+
}));
64+
return id;
65+
},
66+
removePreset: (id) =>
67+
set((state) => ({
68+
presets: state.presets.filter((preset) => preset.id !== id),
69+
})),
70+
}),
71+
{
72+
name: "multica_agent_bulk_edit_presets",
73+
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
74+
partialize: (state) => ({ presets: state.presets }),
75+
merge: (persisted, current) => {
76+
const raw = (persisted as Partial<AgentBulkEditPresetsState> | undefined)?.presets;
77+
if (!raw) return { ...current, presets: [] };
78+
return {
79+
...current,
80+
presets: raw
81+
.map((preset) => ({
82+
...preset,
83+
patch: sanitizePresetPatch(preset.patch),
84+
}))
85+
.slice(0, MAX_PRESETS),
86+
};
87+
},
88+
},
89+
),
90+
);
91+
92+
registerForWorkspaceRehydration(() => useAgentBulkEditPresetsStore.persist.rehydrate());
93+
94+
function sanitizePresetPatch(patch: AgentBulkEditPresetPatch): AgentBulkEditPresetPatch {
95+
const out: AgentBulkEditPresetPatch = {};
96+
97+
if (patch.runtimeId !== undefined) out.runtimeId = patch.runtimeId;
98+
if (patch.model !== undefined) out.model = patch.model;
99+
if (patch.maxConcurrentTasks !== undefined) {
100+
out.maxConcurrentTasks = patch.maxConcurrentTasks;
101+
}
102+
if (patch.customArgsPatch !== undefined) {
103+
const customArgsPatch = patch.customArgsPatch
104+
.map((op) => ({
105+
action: normalizeCustomArgAction(op.action),
106+
value: op.value.trim(),
107+
replacement: op.replacement?.trim(),
108+
}))
109+
.filter((op) =>
110+
op.value.length > 0 &&
111+
(op.action !== "replace" || (op.replacement?.length ?? 0) > 0),
112+
);
113+
if (customArgsPatch.length > 0) out.customArgsPatch = customArgsPatch;
114+
}
115+
if (patch.env !== undefined) {
116+
const env = patch.env
117+
.map((op) => ({
118+
action: op.action === "remove" ? "remove" as const : "set" as const,
119+
key: op.key.trim(),
120+
}))
121+
.filter((op) => op.key.length > 0);
122+
if (env.length > 0) out.env = env;
123+
}
124+
125+
return out;
126+
}
127+
128+
function normalizeCustomArgAction(
129+
action: AgentBulkEditPresetCustomArgOperation["action"],
130+
): AgentBulkEditPresetCustomArgOperation["action"] {
131+
if (action === "replace" || action === "remove") return action;
132+
return "add";
133+
}

packages/core/agents/stores/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ export {
1515
useTranscriptViewStore,
1616
type TranscriptSortDirection,
1717
} from "./transcript-view-store";
18+
export {
19+
useAgentBulkEditPresetsStore,
20+
type AgentBulkEditPreset,
21+
type AgentBulkEditPresetPatch,
22+
type AgentBulkEditPresetEnvOperation,
23+
} from "./bulk-edit-presets-store";

0 commit comments

Comments
 (0)