Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: a soft context limit field in an agent's inference settings

A whole percent from 5 to 100 of the model's context window, sent as
soft_context_percent next to max_subagents. The inference row shows it, the
edit dialog and the create form take it, and an archetype can carry it.
This commit is contained in:
iris 2026-11-15 15:16:59 +01:00 • committed by mara
commit e9af6cf2c7
6 changed files with 109 additions and 7 deletions

View file

@ -714,11 +714,11 @@ text stays.
An opened agent card shows, in its **settings** section, its model and effort under **inference**, each An opened agent card shows, in its **settings** section, its model and effort under **inference**, each
with where it comes from: the swarm, its agent.nix, its own pick, or the with where it comes from: the swarm, its agent.nix, its own pick, or the
default. Below them are the subagent model list the swarm sets, or "its own" default. Below them are the subagent model list the swarm sets, or "its own"
when it sets none, and the subagent cap, which reads "no cap" when unset. when it sets none, the subagent cap, which reads "no cap" when unset, and the
**edit** opens a dialog with the four fields, filled with what the swarm soft context limit, which reads "its own" when unset. **edit** opens a dialog with the five fields, filled with what the swarm
stores; saving does a `PUT /api/agents/{agent}/inference` of stores; saving does a `PUT /api/agents/{agent}/inference` of
the whole value. A field you leave empty stays out of it, so the agent keeps the whole value. A field you leave empty stays out of it, so the agent keeps
its own value for that field; an empty max subagents is no cap. **clear the swarm's setting** sends the `DELETE`. its own value for that field; an empty max subagents is no cap, and an empty soft context limit leaves the agent's own. The soft context limit is a whole percent from 5 to 100 of the model's context window: the agent compacts at the end of a turn once its context passes it, and a turn can run past it. **clear the swarm's setting** sends the `DELETE`.
The card reads `GET /api/agents/{agent}/inference`; before the agent's first The card reads `GET /api/agents/{agent}/inference`; before the agent's first
report reaches the controller, it shows the swarm's stored values only. report reaches the controller, it shows the swarm's stored values only.
[inference](../swarm/inference.md) describes the settings themselves. [inference](../swarm/inference.md) describes the settings themselves.

View file

@ -26,6 +26,11 @@ import { FormStack } from "../../ui/form-stack/FormStack.js";
import "../../ui/badge-row/BadgeRow.css"; import "../../ui/badge-row/BadgeRow.css";
import "./AgentInference.css"; import "./AgentInference.css";
import { MAX_SUBAGENTS, maxSubagentsValue } from "./max-subagents.js"; import { MAX_SUBAGENTS, maxSubagentsValue } from "./max-subagents.js";
import {
MAX_SOFT_CONTEXT_PERCENT,
MIN_SOFT_CONTEXT_PERCENT,
softContextValue,
} from "./soft-context.js";
type Source = "swarm" | "agent_nix" | "local_file" | "default"; type Source = "swarm" | "agent_nix" | "local_file" | "default";
@ -35,6 +40,7 @@ interface Settings {
effort?: string; effort?: string;
subagent_models?: string[]; subagent_models?: string[];
max_subagents?: number; max_subagents?: number;
soft_context_percent?: number;
} }
// Mirrors `EffectiveInference`. // Mirrors `EffectiveInference`.
@ -81,13 +87,14 @@ function inferenceUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/inference`; return `/api/agents/${encodeURIComponent(agent)}/inference`;
} }
// The four inference boxes as typed. Shared by the editor here and the // The inference boxes as typed. Shared by the editor here and the
// create-agent form, which send what `draftBody` makes of them. // create-agent form, which send what `draftBody` makes of them.
export interface InferenceDraft { export interface InferenceDraft {
model: string; model: string;
effort: string; effort: string;
subagents: string; subagents: string;
maxSubagents: string; maxSubagents: string;
softContext: string;
} }
export const EMPTY_INFERENCE_DRAFT: InferenceDraft = { export const EMPTY_INFERENCE_DRAFT: InferenceDraft = {
@ -95,6 +102,7 @@ export const EMPTY_INFERENCE_DRAFT: InferenceDraft = {
effort: "", effort: "",
subagents: "", subagents: "",
maxSubagents: "", maxSubagents: "",
softContext: "",
}; };
// Why the draft would be refused, or null when it is fine to send. // Why the draft would be refused, or null when it is fine to send.
@ -106,6 +114,9 @@ export function draftProblem(d: InferenceDraft): string | null {
.find((p) => p !== null) ?? .find((p) => p !== null) ??
(maxSubagentsValue(d.maxSubagents) === null (maxSubagentsValue(d.maxSubagents) === null
? `max subagents is a whole number from 0 (no subagents) to ${MAX_SUBAGENTS}, or empty for no cap` ? `max subagents is a whole number from 0 (no subagents) to ${MAX_SUBAGENTS}, or empty for no cap`
: null) ??
(softContextValue(d.softContext) === null
? `soft context limit is a whole percent from ${MIN_SOFT_CONTEXT_PERCENT} to ${MAX_SOFT_CONTEXT_PERCENT}, or empty for the agent's own`
: null) : null)
); );
} }
@ -115,7 +126,8 @@ export function draftIsEmpty(d: InferenceDraft): boolean {
d.model.trim() === "" && d.model.trim() === "" &&
d.effort === "" && d.effort === "" &&
parseModels(d.subagents).length === 0 && parseModels(d.subagents).length === 0 &&
d.maxSubagents.trim() === "" d.maxSubagents.trim() === "" &&
d.softContext.trim() === ""
); );
} }
@ -128,6 +140,8 @@ export function draftBody(d: InferenceDraft): Settings {
if (models.length > 0) body.subagent_models = models; if (models.length > 0) body.subagent_models = models;
const max = maxSubagentsValue(d.maxSubagents); const max = maxSubagentsValue(d.maxSubagents);
if (typeof max === "number") body.max_subagents = max; if (typeof max === "number") body.max_subagents = max;
const soft = softContextValue(d.softContext);
if (typeof soft === "number") body.soft_context_percent = soft;
return body; return body;
} }
@ -174,6 +188,14 @@ export function InferenceFields({
pattern="[0-9]*" pattern="[0-9]*"
onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })} onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })}
/> />
<TextField
id={`${idPrefix}-soft-context`}
label="soft context limit (%)"
value={draft.softContext}
placeholder="empty for the agent's own"
pattern="[0-9]*"
onInput={(softContext) => onChange({ ...draft, softContext })}
/>
{problem ? <Hint tone="error">{problem}</Hint> : null} {problem ? <Hint tone="error">{problem}</Hint> : null}
</> </>
); );
@ -278,11 +300,22 @@ export function AgentInference({ agent }: { agent: string }) {
} }
/> />
</li> </li>
<li>
<Badge
label="soft context limit"
value={
configured?.soft_context_percent === undefined
? "its own"
: `${configured.soft_context_percent}%`
}
title="the agent compacts at the end of a turn once its context passes this share of the window; a turn can run past it"
/>
</li>
</ul> </ul>
<Badge <Badge
variant="quiet" variant="quiet"
value="edit" value="edit"
title={`set the swarm's model, effort, subagent models and subagent cap for ${agent}`} title={`set the swarm's model, effort, subagent models, subagent cap and soft context limit for ${agent}`}
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
/> />
</div> </div>
@ -325,6 +358,7 @@ function InferenceForm({
effort: configured?.effort ?? "", effort: configured?.effort ?? "",
subagents: (configured?.subagent_models ?? []).join(", "), subagents: (configured?.subagent_models ?? []).join(", "),
maxSubagents: configured?.max_subagents?.toString() ?? "", maxSubagents: configured?.max_subagents?.toString() ?? "",
softContext: configured?.soft_context_percent?.toString() ?? "",
}); });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null); const [problem, setProblem] = useState<ProblemDetails | null>(null);

View file

@ -0,0 +1,22 @@
// The create form's and the inference dialog's `soft_context_percent` field,
// kept out of the component so `npm test` can run it.
/** The lowest `soft_context_percent` the swarm accepts. */
export const MIN_SOFT_CONTEXT_PERCENT = 5;
/** The highest: the whole context window. */
export const MAX_SOFT_CONTEXT_PERCENT = 100;
/**
* `soft_context_percent` as typed: `undefined` for empty (the agent's own
* default), the number for a whole number from [`MIN_SOFT_CONTEXT_PERCENT`]
* to [`MAX_SOFT_CONTEXT_PERCENT`], else `null`.
*/
export function softContextValue(text: string): number | null | undefined {
const t = text.trim();
if (t === "") return undefined;
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= MIN_SOFT_CONTEXT_PERCENT && n <= MAX_SOFT_CONTEXT_PERCENT
? n
: null;
}

View file

@ -14,6 +14,7 @@ export interface Archetype {
effort?: string; effort?: string;
subagent_models?: string[]; subagent_models?: string[];
max_subagents?: number; max_subagents?: number;
soft_context_percent?: number;
}; };
prompt?: string; prompt?: string;
/** The archetype's icon as SVG text. */ /** The archetype's icon as SVG text. */
@ -69,6 +70,10 @@ export function prefill(
inference.max_subagents === undefined inference.max_subagents === undefined
? "" ? ""
: String(inference.max_subagents), : String(inference.max_subagents),
softContext:
inference.soft_context_percent === undefined
? ""
: String(inference.soft_context_percent),
}, },
}; };
} }

View file

@ -15,7 +15,13 @@ const empty = {
permissions: defaults, permissions: defaults,
cpu: "", cpu: "",
memory: "", memory: "",
inference: { model: "", effort: "", subagents: "", maxSubagents: "" }, inference: {
model: "",
effort: "",
subagents: "",
maxSubagents: "",
softContext: "",
},
}; };
test("no archetype is the default permissions and empty settings", () => { test("no archetype is the default permissions and empty settings", () => {
@ -34,6 +40,7 @@ test("an archetype's settings fill the form", () => {
effort: "high", effort: "high",
subagent_models: ["haiku", "sonnet"], subagent_models: ["haiku", "sonnet"],
max_subagents: 0, max_subagents: 0,
soft_context_percent: 20,
}, },
}; };
assert.deepEqual(prefill(reviewer, defaults), { assert.deepEqual(prefill(reviewer, defaults), {
@ -45,6 +52,7 @@ test("an archetype's settings fill the form", () => {
effort: "high", effort: "high",
subagents: "haiku, sonnet", subagents: "haiku, sonnet",
maxSubagents: "0", maxSubagents: "0",
softContext: "20",
}, },
}); });
}); });

View file

@ -0,0 +1,33 @@
// The soft context limit field: what counts as empty, valid and refused. Run
// by `npm test`, which imports the `.ts` module as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
MAX_SOFT_CONTEXT_PERCENT,
MIN_SOFT_CONTEXT_PERCENT,
softContextValue,
} from "../src/agent/detail/soft-context.ts";
test("an empty box leaves the agent on its own limit", () => {
assert.equal(softContextValue(""), undefined);
assert.equal(softContextValue(" "), undefined);
});
test("a whole percent from 5 to 100 is taken", () => {
assert.equal(softContextValue("20"), 20);
assert.equal(softContextValue(" 75 "), 75);
assert.equal(
softContextValue(String(MIN_SOFT_CONTEXT_PERCENT)),
MIN_SOFT_CONTEXT_PERCENT,
);
assert.equal(
softContextValue(String(MAX_SOFT_CONTEXT_PERCENT)),
MAX_SOFT_CONTEXT_PERCENT,
);
});
test("anything else is refused", () => {
for (const text of ["0", "4", "101", "-20", "20.5", "abc", "1e1", "20%"]) {
assert.equal(softContextValue(text), null, text);
}
});