swarm UI: send a message to an agent, without its hive
The agent view in the swarm UI gets a message box. The text reaches the
agent's harness straight from swarm-controller over the swarm queue and
becomes an inbox turn from the operator's authelia username. hive-c0re is
not in the path.
- gateway: `/api/` forwards authelia's `Remote-User`, replacing any the
client sent.
- swarm-controller: `POST /api/agents/{name}/input` takes that header as
the sender (401 without it) and sends `{from, body}` as a NATS request
on `$SWARM.input.<agent>`. No subscriber is a 503.
- swarm-nats-auth: the controller is granted publish on `$SWARM.input.*`;
no hive or agent grant names the family, so the harness can trust
`from`.
- hive-agent: a task subscribes to the agent's own input subject and
pushes each message into the serve loop's existing reminder channel,
renamed `local_msg_*` since it now carries both.
The harness does not reply to the request: no agent grant covers a reply
subject. The controller therefore answers 202 once the request times out
(2s) and 503 on the server's no-responders status.
Closes #4828
This commit is contained in:
parent
5971b51e65
commit
48ac4ac423
13 changed files with 664 additions and 24 deletions
|
|
@ -0,0 +1,17 @@
|
|||
/* <AgentInputForm> — one row: the text field takes the width, the send
|
||||
button sits at its baseline. */
|
||||
.ui-agent-input {
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
.ui-agent-input-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.ui-agent-input-form > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.ui-agent-input-sent {
|
||||
margin-block-start: 0.25rem;
|
||||
color: var(--green);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// <AgentInputForm> — text for one agent, under its swarm terminal.
|
||||
// POSTs `/api/agents/{name}/input`: 202 once the agent's harness holds the
|
||||
// message, 503 when it is not listening, problem+json otherwise. The agent
|
||||
// sees it as an inbox turn from the logged-in user (the controller takes the
|
||||
// sender from the gateway, not from this request).
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import "./AgentInputForm.css";
|
||||
|
||||
type SubmitState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "sent" }
|
||||
| { status: "error"; problem: ProblemDetails };
|
||||
|
||||
export function AgentInputForm({ agentName }: { agentName: string }) {
|
||||
const [body, setBody] = useState("");
|
||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
setResult({ status: "submitting" });
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/input`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
},
|
||||
);
|
||||
if (!r.ok) {
|
||||
setResult({ status: "error", problem: await readApiError(r) });
|
||||
return;
|
||||
}
|
||||
setResult({ status: "sent" });
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
setResult({ status: "error", problem: { detail: String(err) } });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="ui-agent-input">
|
||||
<form class="ui-agent-input-form" onSubmit={submit}>
|
||||
<TextField
|
||||
id={`agent-input-${agentName}`}
|
||||
label={`message ${agentName}`}
|
||||
value={body}
|
||||
required
|
||||
onInput={setBody}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={result.status === "submitting"}
|
||||
>
|
||||
{result.status === "submitting" ? "sending…" : "send"}
|
||||
</Button>
|
||||
</form>
|
||||
{result.status === "sent" && <p class="ui-agent-input-sent">sent</p>}
|
||||
{result.status === "error" && (
|
||||
<ApiErrorPanel context="failed to send" problem={result.problem} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
|
||||
// page, a small read-only preview embedded below `AgentsPage`'s detail
|
||||
// panel fields. No input — sending input back to the agent is explicitly
|
||||
// follow-up scope, not this issue's. Consumes swarm-controller's
|
||||
// page, a small preview embedded below `AgentsPage`'s detail panel fields, with
|
||||
// an `AgentInputForm` under an agent's terminal (a subagent's has none: it
|
||||
// takes no input of its own). Consumes swarm-controller's
|
||||
// `GET /api/agents/{name}/term/stream`, or for a subagent
|
||||
// `GET /api/agents/{name}/subagents/{subagent}/term/stream`, via
|
||||
// `useSwarmTermStream`, rendering through the same `@hive/shared` `Row`
|
||||
|
|
@ -49,6 +49,7 @@ import {
|
|||
useSwarmAgentStateStream,
|
||||
type TokenUsage,
|
||||
} from "./useSwarmAgentStateStream.js";
|
||||
import { AgentInputForm } from "./AgentInputForm.js";
|
||||
import "./AgentTermPreview.css";
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
|
|
@ -256,6 +257,7 @@ export function AgentTermPreview({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{subagent === undefined && <AgentInputForm agentName={agentName} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
// <AgentTerminalPage> — the swarm terminal tab's full page, mounted at
|
||||
// `/agents/:name/terminal`. The MVP `AgentTermPreview` (below `AgentsPage`'s
|
||||
// detail panel) is explicitly capped and read-only; this is that same
|
||||
// detail panel) is height-capped; this is that same
|
||||
// component's `fullHeight` mount — no 12em cap, floating header badges
|
||||
// shown (there's no detail-panel `<dl>` here to duplicate them). Sending
|
||||
// input back to the agent is still explicit follow-up scope, same as the
|
||||
// preview's own file comment says — this page doesn't change that.
|
||||
// shown (there's no detail-panel `<dl>` here to duplicate them).
|
||||
//
|
||||
// Registers itself in the dynamic-tabs row on mount (not just when opened
|
||||
// via the preview's expand trigger) — a bookmark, a shared link, or a
|
||||
|
|
|
|||
|
|
@ -651,9 +651,7 @@ export function AgentsPage() {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{/* MVP scope per mara's own ruling: a small read-only
|
||||
preview, no header/no input — the full terminal
|
||||
(+ sending input back) is explicit follow-up scope. */}
|
||||
{/* The agent's live terminal and its input box. */}
|
||||
<AgentTermPreview agentName={detailTarget.name} />
|
||||
<SubagentTerms
|
||||
key={detailTarget.name}
|
||||
|
|
|
|||
Loading…
Reference in a new issue