Set resource caps and inference when creating an agent
POST /api/agents takes optional limits and inference objects, checked by the same rules as the two PUT routes before anything is queued and stored ahead of the job graph. The create form gets collapsed sections for both, reusing the fields of the detail-panel editors.
This commit is contained in:
parent
aaac754356
commit
e36a15de91
8 changed files with 389 additions and 100 deletions
|
|
@ -296,6 +296,18 @@ row shows the new value before the agent runs under it. The panel reads
|
|||
`GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits)
|
||||
sets the hive's own limits.
|
||||
|
||||
### Settings at creation
|
||||
|
||||
The **+ agent** dialog has two collapsed sections, **resource caps** and
|
||||
**inference**, with the same fields as the detail panel's editors. Leave them
|
||||
closed or empty and the request is the plain name and hive. Fill them and
|
||||
`POST /api/agents` carries them as optional `limits` and `inference` objects,
|
||||
the same shapes the two `PUT` routes take. The controller checks them with the
|
||||
`PUT` rules before it queues anything, so a bad value gets a refusal with its
|
||||
reason and creates no agent. It stores them ahead of the job graph, so they
|
||||
are in place before the agent first runs. Creating a name that already exists with no settings
|
||||
leaves any stored ones alone.
|
||||
|
||||
### Linking external accounts
|
||||
|
||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||
|
|
|
|||
|
|
@ -88,6 +88,100 @@ function inferenceUrl(agent: string): string {
|
|||
return `/api/agents/${encodeURIComponent(agent)}/inference`;
|
||||
}
|
||||
|
||||
// The four inference boxes as typed. Shared by the editor here and the
|
||||
// create-agent form, which send what `draftBody` makes of them.
|
||||
export interface InferenceDraft {
|
||||
model: string;
|
||||
effort: string;
|
||||
subagents: string;
|
||||
maxSubagents: string;
|
||||
}
|
||||
|
||||
export const EMPTY_INFERENCE_DRAFT: InferenceDraft = {
|
||||
model: "",
|
||||
effort: "",
|
||||
subagents: "",
|
||||
maxSubagents: "",
|
||||
};
|
||||
|
||||
// Why the draft would be refused, or null when it is fine to send.
|
||||
export function draftProblem(d: InferenceDraft): string | null {
|
||||
return (
|
||||
modelNameProblem(d.model.trim()) ??
|
||||
parseModels(d.subagents)
|
||||
.map(modelNameProblem)
|
||||
.find((p) => p !== null) ??
|
||||
(maxSubagentsValue(d.maxSubagents) === null
|
||||
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
export function draftIsEmpty(d: InferenceDraft): boolean {
|
||||
return (
|
||||
d.model.trim() === "" &&
|
||||
d.effort === "" &&
|
||||
parseModels(d.subagents).length === 0 &&
|
||||
d.maxSubagents.trim() === ""
|
||||
);
|
||||
}
|
||||
|
||||
// Only the filled fields: an empty one leaves the agent on its own value.
|
||||
export function draftBody(d: InferenceDraft): Settings {
|
||||
const body: Settings = {};
|
||||
if (d.model.trim() !== "") body.model = d.model.trim();
|
||||
if (d.effort !== "") body.effort = d.effort;
|
||||
const models = parseModels(d.subagents);
|
||||
if (models.length > 0) body.subagent_models = models;
|
||||
const max = maxSubagentsValue(d.maxSubagents);
|
||||
if (typeof max === "number") body.max_subagents = max;
|
||||
return body;
|
||||
}
|
||||
|
||||
export function InferenceFields({
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
draft: InferenceDraft;
|
||||
onChange: (d: InferenceDraft) => void;
|
||||
}) {
|
||||
const problem = draftProblem(draft);
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
id="inference-model"
|
||||
label="model"
|
||||
value={draft.model}
|
||||
placeholder="e.g. opus"
|
||||
onInput={(model) => onChange({ ...draft, model })}
|
||||
/>
|
||||
<SelectField
|
||||
id="inference-effort"
|
||||
label="effort"
|
||||
value={draft.effort}
|
||||
options={EFFORT_OPTIONS}
|
||||
onChange={(effort) => onChange({ ...draft, effort })}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-subagent-models"
|
||||
label="subagent models"
|
||||
value={draft.subagents}
|
||||
placeholder="e.g. haiku, sonnet"
|
||||
onInput={(subagents) => onChange({ ...draft, subagents })}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-max-subagents"
|
||||
label="max subagents"
|
||||
value={draft.maxSubagents}
|
||||
placeholder="empty for no cap"
|
||||
pattern="[0-9]*"
|
||||
onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })}
|
||||
/>
|
||||
{problem ? <p class="ui-agent-inference-problem">{problem}</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentInference({ agent }: { agent: string }) {
|
||||
const [view, setView] = useState<View | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -219,29 +313,17 @@ function InferenceForm({
|
|||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [model, setModel] = useState(configured?.model ?? "");
|
||||
const [effort, setEffort] = useState(configured?.effort ?? "");
|
||||
const [subagents, setSubagents] = useState(
|
||||
(configured?.subagent_models ?? []).join(", "),
|
||||
);
|
||||
const [maxSubagents, setMaxSubagents] = useState(
|
||||
configured?.max_subagents?.toString() ?? "",
|
||||
);
|
||||
const [draft, setDraft] = useState<InferenceDraft>({
|
||||
model: configured?.model ?? "",
|
||||
effort: configured?.effort ?? "",
|
||||
subagents: (configured?.subagent_models ?? []).join(", "),
|
||||
maxSubagents: configured?.max_subagents?.toString() ?? "",
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
||||
|
||||
const subagentList = parseModels(subagents);
|
||||
const formProblem =
|
||||
modelNameProblem(model.trim()) ??
|
||||
subagentList.map(modelNameProblem).find((p) => p !== null) ??
|
||||
(maxSubagentsValue(maxSubagents) === null
|
||||
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
|
||||
: null);
|
||||
const empty =
|
||||
model.trim() === "" &&
|
||||
effort === "" &&
|
||||
subagentList.length === 0 &&
|
||||
maxSubagents.trim() === "";
|
||||
const formProblem = draftProblem(draft);
|
||||
const empty = draftIsEmpty(draft);
|
||||
|
||||
async function send(init: RequestInit) {
|
||||
setBusy(true);
|
||||
|
|
@ -261,16 +343,10 @@ function InferenceForm({
|
|||
|
||||
function save(e: Event) {
|
||||
e.preventDefault();
|
||||
const body: Settings = {};
|
||||
if (model.trim() !== "") body.model = model.trim();
|
||||
if (effort !== "") body.effort = effort;
|
||||
if (subagentList.length > 0) body.subagent_models = subagentList;
|
||||
const max = maxSubagentsValue(maxSubagents);
|
||||
if (typeof max === "number") body.max_subagents = max;
|
||||
void send({
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(draftBody(draft)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -284,38 +360,7 @@ function InferenceForm({
|
|||
running.
|
||||
</p>
|
||||
<form class="ui-agent-inference-form" onSubmit={save}>
|
||||
<TextField
|
||||
id="inference-model"
|
||||
label="model"
|
||||
value={model}
|
||||
placeholder="e.g. opus"
|
||||
onInput={setModel}
|
||||
/>
|
||||
<SelectField
|
||||
id="inference-effort"
|
||||
label="effort"
|
||||
value={effort}
|
||||
options={EFFORT_OPTIONS}
|
||||
onChange={setEffort}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-subagent-models"
|
||||
label="subagent models"
|
||||
value={subagents}
|
||||
placeholder="e.g. haiku, sonnet"
|
||||
onInput={setSubagents}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-max-subagents"
|
||||
label="max subagents"
|
||||
value={maxSubagents}
|
||||
placeholder="empty for no cap"
|
||||
pattern="[0-9]*"
|
||||
onInput={setMaxSubagents}
|
||||
/>
|
||||
{formProblem ? (
|
||||
<p class="ui-agent-inference-problem">{formProblem}</p>
|
||||
) : null}
|
||||
<InferenceFields draft={draft} onChange={setDraft} />
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="saving failed" problem={problem} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,55 @@ function limitsUrl(agent: string): string {
|
|||
return `/api/agents/${encodeURIComponent(agent)}/limits`;
|
||||
}
|
||||
|
||||
// The caps as typed, as the body the route takes: only the filled fields, so
|
||||
// an empty box leaves that field to the hive.
|
||||
export function capsBody(cpu: string, memory: string): Caps {
|
||||
const body: Caps = {};
|
||||
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
|
||||
if (memory.trim() !== "") body.memory_max = memory.trim();
|
||||
return body;
|
||||
}
|
||||
|
||||
// The two cap boxes with their hints, shared by the editor here and the
|
||||
// create-agent form.
|
||||
export function LimitsFields({
|
||||
cpu,
|
||||
memory,
|
||||
onCpu,
|
||||
onMemory,
|
||||
}: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
onCpu: (v: string) => void;
|
||||
onMemory: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
id="limits-cpu-quota"
|
||||
label="cpu quota"
|
||||
value={cpu}
|
||||
placeholder="e.g. 400%"
|
||||
onInput={onCpu}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A percentage of one core: 100% is one core, 400% is four.
|
||||
</p>
|
||||
<TextField
|
||||
id="limits-memory-max"
|
||||
label="memory max"
|
||||
value={memory}
|
||||
placeholder="e.g. 8G"
|
||||
onInput={onMemory}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A size such as 512M or 8G, a percentage of the machine's memory such as
|
||||
50%, or infinity.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentLimits({ agent }: { agent: string }) {
|
||||
const [caps, setCaps] = useState<Caps | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -152,13 +201,10 @@ function LimitsForm({
|
|||
e.preventDefault();
|
||||
// Only the filled fields: a PUT replaces the stored value whole, so an
|
||||
// empty box is cleared and the hive decides that field again.
|
||||
const body: Caps = {};
|
||||
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
|
||||
if (memory.trim() !== "") body.memory_max = memory.trim();
|
||||
void send({
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(capsBody(cpu, memory)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -171,27 +217,12 @@ function LimitsForm({
|
|||
agent takes it at its next spawn or rebuild.
|
||||
</p>
|
||||
<form class="ui-agent-limits-form" onSubmit={save}>
|
||||
<TextField
|
||||
id="limits-cpu-quota"
|
||||
label="cpu quota"
|
||||
value={cpu}
|
||||
placeholder="e.g. 400%"
|
||||
onInput={setCpu}
|
||||
<LimitsFields
|
||||
cpu={cpu}
|
||||
memory={memory}
|
||||
onCpu={setCpu}
|
||||
onMemory={setMemory}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A percentage of one core: 100% is one core, 400% is four.
|
||||
</p>
|
||||
<TextField
|
||||
id="limits-memory-max"
|
||||
label="memory max"
|
||||
value={memory}
|
||||
placeholder="e.g. 8G"
|
||||
onInput={setMemory}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A size such as 512M or 8G, a percentage of the machine's memory such
|
||||
as 50%, or infinity.
|
||||
</p>
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="the caps were not saved" problem={problem} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,21 @@
|
|||
align-items: flex-start;
|
||||
gap: 0.75em;
|
||||
}
|
||||
/* Optional settings, collapsed so the plain name-and-hive form stays short. */
|
||||
.create-agent-section {
|
||||
align-self: stretch;
|
||||
}
|
||||
.create-agent-section > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.create-agent-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75em;
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
.create-agent-result {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ import {
|
|||
type SelectOption,
|
||||
} from "../ui/select-field/SelectField.js";
|
||||
import { Button } from "../ui/button/Button.js";
|
||||
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
|
||||
import {
|
||||
draftBody,
|
||||
draftProblem,
|
||||
EMPTY_INFERENCE_DRAFT,
|
||||
InferenceFields,
|
||||
type InferenceDraft,
|
||||
} from "../agent/detail/AgentInference.js";
|
||||
import "./CreateAgentForm.css";
|
||||
|
||||
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
||||
|
|
@ -88,6 +96,13 @@ export function CreateAgentForm({
|
|||
const [hives, setHives] = useState<HiveEntry[] | null>(null);
|
||||
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
|
||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||
// The optional settings sections, left empty unless the operator fills them.
|
||||
const [cpu, setCpu] = useState("");
|
||||
const [memory, setMemory] = useState("");
|
||||
const [inference, setInference] = useState<InferenceDraft>(
|
||||
EMPTY_INFERENCE_DRAFT,
|
||||
);
|
||||
const inferenceProblem = draftProblem(inference);
|
||||
|
||||
// Loaded once, not re-fetched on submit: the roster changes rarely
|
||||
// enough (a nix-level swarm config change) that staleness within one
|
||||
|
|
@ -119,10 +134,19 @@ export function CreateAgentForm({
|
|||
e.preventDefault();
|
||||
setResult({ status: "submitting" });
|
||||
try {
|
||||
// A section left empty is left out, so the request is the plain
|
||||
// name-and-hive one unless the operator set something.
|
||||
const limits = capsBody(cpu, memory);
|
||||
const settings = draftBody(inference);
|
||||
const r = await fetch("/api/agents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name, hive }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
hive,
|
||||
...(Object.keys(limits).length > 0 ? { limits } : {}),
|
||||
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
setResult({ status: "error", problem: await readApiError(r) });
|
||||
|
|
@ -142,7 +166,9 @@ export function CreateAgentForm({
|
|||
<Panel title="create agent" icon="🤖" onClose={onClose}>
|
||||
<p class="create-agent-intro">
|
||||
Create a new agent's swarm-level identity. This only queues the job
|
||||
— check <Link href="/jobs">jobs</Link> to watch it settle.
|
||||
— check <Link href="/jobs">jobs</Link> to watch it settle. Resource
|
||||
caps and inference are optional; leave them closed to keep the
|
||||
defaults.
|
||||
</p>
|
||||
{hivesError && (
|
||||
<ApiErrorPanel
|
||||
|
|
@ -176,10 +202,31 @@ export function CreateAgentForm({
|
|||
: "select a hive"
|
||||
}
|
||||
/>
|
||||
<details class="create-agent-section">
|
||||
<summary>resource caps</summary>
|
||||
<div class="create-agent-section-body">
|
||||
<LimitsFields
|
||||
cpu={cpu}
|
||||
memory={memory}
|
||||
onCpu={setCpu}
|
||||
onMemory={setMemory}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<details class="create-agent-section">
|
||||
<summary>inference</summary>
|
||||
<div class="create-agent-section-body">
|
||||
<InferenceFields draft={inference} onChange={setInference} />
|
||||
</div>
|
||||
</details>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={result.status === "submitting" || !hivesReady}
|
||||
disabled={
|
||||
result.status === "submitting" ||
|
||||
!hivesReady ||
|
||||
inferenceProblem !== null
|
||||
}
|
||||
>
|
||||
{result.status === "submitting" ? "creating…" : "create"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ pub struct Caps {
|
|||
|
||||
impl Caps {
|
||||
/// The value to store, or why it is refused.
|
||||
fn into_stored(self) -> Result<AgentCaps, String> {
|
||||
pub(crate) fn into_stored(self) -> Result<AgentCaps, String> {
|
||||
Ok(AgentCaps {
|
||||
cpu_quota: self.cpu_quota.as_deref().map(cpu_quota).transpose()?,
|
||||
memory_max: self.memory_max.as_deref().map(memory_max).transpose()?,
|
||||
|
|
@ -253,18 +253,28 @@ pub(crate) async fn put_agent_limits(
|
|||
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
|
||||
));
|
||||
}
|
||||
let store = open_or_create(&client).await?;
|
||||
store(&client, &agent, &value).await?;
|
||||
Ok(Json(value.into()))
|
||||
}
|
||||
|
||||
/// Replace `agent`'s stored caps with `value`.
|
||||
pub(crate) async fn store(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
value: &AgentCaps,
|
||||
) -> Result<(), ProblemDetails> {
|
||||
let store = open_or_create(client).await?;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let (current, revision, undecodable) = read_or_default::<AgentCaps>(&store, &agent)
|
||||
let (current, revision, undecodable) = read_or_default::<AgentCaps>(&store, agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
if (current == value && !undecodable)
|
||||
|| write(&store, &agent, &value, revision)
|
||||
if (current == *value && !undecodable)
|
||||
|| write(&store, agent, value, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
tracing::info!(%agent, ?value, "set agent limits");
|
||||
return Ok(Json(value.into()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ pub(crate) fn check_model(name: &str, what: &str) -> Result<(), String> {
|
|||
|
||||
impl InferenceSettings {
|
||||
/// The value to store, or why it is refused.
|
||||
fn into_stored(self) -> Result<AgentInference, String> {
|
||||
pub(crate) fn into_stored(self) -> Result<AgentInference, String> {
|
||||
if let Some(model) = &self.model {
|
||||
check_model(model, "model")?;
|
||||
}
|
||||
|
|
@ -313,18 +313,28 @@ pub(crate) async fn put_agent_inference(
|
|||
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
|
||||
));
|
||||
}
|
||||
let store = open(&client).await?;
|
||||
store(&client, &agent, &value).await?;
|
||||
Ok(Json(value.into()))
|
||||
}
|
||||
|
||||
/// Replace `agent`'s stored inference settings with `value`.
|
||||
pub(crate) async fn store(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
value: &AgentInference,
|
||||
) -> Result<(), ProblemDetails> {
|
||||
let store = open(client).await?;
|
||||
for _ in 0..MAX_ATTEMPTS {
|
||||
let (current, revision, undecodable) = read_or_default::<AgentInference>(&store, &agent)
|
||||
let (current, revision, undecodable) = read_or_default::<AgentInference>(&store, agent)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?;
|
||||
if (current == value && !undecodable)
|
||||
|| write(&store, &agent, &value, revision)
|
||||
if (current == *value && !undecodable)
|
||||
|| write(&store, agent, value, revision)
|
||||
.await
|
||||
.map_err(|e| queue_problem(&e))?
|
||||
{
|
||||
tracing::info!(%agent, ?value, "set inference settings");
|
||||
return Ok(Json(value.into()));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(queue_problem(&anyhow::anyhow!(
|
||||
|
|
|
|||
|
|
@ -1727,6 +1727,15 @@ async fn get_agents_status(
|
|||
struct CreateAgentRequest {
|
||||
name: String,
|
||||
hive: String,
|
||||
/// CPU and memory caps to set for the agent, as `PUT
|
||||
/// /api/agents/{name}/limits` takes them. Absent leaves any caps already
|
||||
/// stored for the name alone.
|
||||
#[serde(default)]
|
||||
limits: Option<agent_limits::Caps>,
|
||||
/// Inference settings to set for the agent, as `PUT
|
||||
/// /api/agents/{name}/inference` takes them. Absent as `limits`.
|
||||
#[serde(default)]
|
||||
inference: Option<inference::InferenceSettings>,
|
||||
}
|
||||
|
||||
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or
|
||||
|
|
@ -1780,9 +1789,9 @@ struct CreateAgentResponse {
|
|||
request_body = CreateAgentRequest,
|
||||
responses(
|
||||
(status = 200, description = "job chain queued", body = CreateAgentResponse),
|
||||
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, or `hive` is not in this swarm (problem+json)", body = String),
|
||||
(status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, or `limits` or `inference` holds a value the matching `PUT` refuses (problem+json)", body = String),
|
||||
(status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String),
|
||||
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown (problem+json)", body = String),
|
||||
(status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits` or `inference` is set and cannot be stored (problem+json)", body = String),
|
||||
(status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
|
|
@ -1795,6 +1804,24 @@ async fn create_agent(
|
|||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
|
||||
// Settings are checked before anything is created, so a bad value stops
|
||||
// the request rather than leaving an agent without them.
|
||||
let limits = req
|
||||
.limits
|
||||
.map(agent_limits::Caps::into_stored)
|
||||
.transpose()
|
||||
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
|
||||
let inference = req
|
||||
.inference
|
||||
.map(inference::InferenceSettings::into_stored)
|
||||
.transpose()
|
||||
.map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?;
|
||||
let settings_queue = if limits.is_some() || inference.is_some() {
|
||||
Some(schedules::queue(&state).map_err(schedules::refused)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Two checks, and the second is the one that makes the field worth
|
||||
// having: `Ident::parse` says the string is *shaped* like a hive name,
|
||||
// and the roster says it *is* one. Without the roster check a typo is
|
||||
|
|
@ -1874,6 +1901,16 @@ async fn create_agent(
|
|||
);
|
||||
return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail));
|
||||
}
|
||||
// Stored ahead of the job graph: the hive reads these by agent name when it
|
||||
// runs the agent, and a failed write here leaves nothing created.
|
||||
if let Some(client) = &settings_queue {
|
||||
if let Some(limits) = &limits {
|
||||
agent_limits::store(client, &agent, limits).await?;
|
||||
}
|
||||
if let Some(inference) = &inference {
|
||||
inference::store(client, &agent, inference).await?;
|
||||
}
|
||||
}
|
||||
let mut sched = state
|
||||
.jobq
|
||||
.lock()
|
||||
|
|
@ -3658,6 +3695,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1maa".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -3696,6 +3735,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -3710,6 +3751,72 @@ mod tests {
|
|||
assert!(queued > 0, "an accepted creation must queue work");
|
||||
}
|
||||
|
||||
/// A request with no settings is the one callers sent before the fields
|
||||
/// existed, and each field is checked by the same rules as its `PUT`.
|
||||
#[test]
|
||||
fn a_creation_request_takes_optional_settings() {
|
||||
let plain: super::CreateAgentRequest =
|
||||
serde_json::from_str(r#"{"name":"atlas","hive":"pr1ma"}"#).expect("plain body");
|
||||
assert!(plain.limits.is_none() && plain.inference.is_none());
|
||||
|
||||
let full: super::CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"},
|
||||
"inference":{"model":"opus","effort":"high"}}"#,
|
||||
)
|
||||
.expect("body with settings");
|
||||
assert!(full.limits.is_some() && full.inference.is_some());
|
||||
|
||||
let typo = serde_json::from_str::<super::CreateAgentRequest>(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu":"400%"}}"#,
|
||||
);
|
||||
assert!(typo.is_err(), "an unknown cap field must be refused");
|
||||
}
|
||||
|
||||
/// A bad cap or model must stop the creation before the graph is queued:
|
||||
/// the point of taking settings in the creating request is that a refusal
|
||||
/// leaves no agent without them.
|
||||
#[tokio::test]
|
||||
async fn bad_settings_are_refused_before_anything_is_queued() {
|
||||
use axum::http::StatusCode;
|
||||
let bad: [(&str, &str); 2] = [
|
||||
(r#"{"cpu_quota":"lots"}"#, "limits"),
|
||||
(r#"{"model":"-opus"}"#, "inference"),
|
||||
];
|
||||
for (value, field) in bad {
|
||||
let (state, sched) = state_with_roster();
|
||||
let body = format!(r#"{{"name":"atlas","hive":"pr1ma","{field}":{value}}}"#);
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(&body).expect("body parses");
|
||||
let err = super::create_agent(axum::extract::State(state), axum::Json(req))
|
||||
.await
|
||||
.expect_err("a bad setting must be refused");
|
||||
assert_eq!(
|
||||
axum::response::IntoResponse::into_response(err).status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{field}"
|
||||
);
|
||||
assert_eq!(node_count(&sched), 0, "{field}: nothing may be queued");
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that cannot be stored, here because there is no queue, refuse
|
||||
/// the creation rather than leaving an agent without them.
|
||||
#[tokio::test]
|
||||
async fn settings_that_cannot_be_stored_refuse_the_creation() {
|
||||
let (state, sched) = state_with_roster();
|
||||
let req: super::CreateAgentRequest = serde_json::from_str(
|
||||
r#"{"name":"atlas","hive":"pr1ma","limits":{"cpu_quota":"400%"}}"#,
|
||||
)
|
||||
.expect("body parses");
|
||||
let err = super::create_agent(axum::extract::State(state), axum::Json(req))
|
||||
.await
|
||||
.expect_err("no queue means the caps cannot be stored");
|
||||
assert_eq!(
|
||||
axum::response::IntoResponse::into_response(err).status(),
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(node_count(&sched), 0, "nothing may be queued");
|
||||
}
|
||||
|
||||
/// `state_with_roster` with a second hive, `sec0nd`.
|
||||
fn state_with_two_hives() -> (super::AppState, SharedSched) {
|
||||
let (state, sched) = state_with_roster();
|
||||
|
|
@ -3735,6 +3842,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: hive.to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -4855,6 +4964,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -4906,6 +5017,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -4953,6 +5066,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5008,6 +5123,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
@ -5210,6 +5327,8 @@ mod tests {
|
|||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
inference: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
Loading…
Reference in a new issue