agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
139 lines (138 loc) • 7.06 kB
TypeScript
/**
* hosting/sessionWire — the wire grammar for "show me my own conversations",
* owned once.
*
* A hosted agent stores conversations so a person can come back to one. Until
* now the only way back was to already know the session id: the host could
* hydrate what you named and nothing else. That is fine for a client that
* remembers, and useless for the screen everyone actually builds — a sidebar of
* your past chats.
*
* Two operations answer it, and the shape of the pair is the security design:
*
* `{ op: 'session-list' }` → the VERIFIED caller's own
* sessions, newest first
* `{ op: 'session-transcript', sessionId }` → that session's messages,
* IF the verified caller
* owns it
*
* ── Why both REQUIRE verified identity ───────────────────────────────────────
* `HostRequest.userId` is caller data — a string anybody can send, as the port
* has said since 9.12. Listing "your" sessions from an unverified header is
* enumeration with a friendly interface: guess an id, read a stranger's
* conversations. So these ops are refused outright at a door that does not
* verify ({@link SessionOpNeedsIdentityError}) rather than served under a name
* nobody proved. This is the one place in the hosting layer where a feature is
* gated on a SECURITY control being present, and it is gated because the
* feature without the control is a vulnerability rather than a smaller feature.
*
* ── Why a foreign transcript is a 404 and not a 403 ──────────────────────────
* "That session exists but is not yours" tells an attacker which ids are real.
* A session that does not exist, one that exists and belongs to somebody else,
* and one whose store lost it are ONE answer here, byte-identical but for the
* id the caller already knew. The same law the artifact wire's not-found
* follows, for the same reason.
*
* ── What a transcript actually contains ──────────────────────────────────────
* The stored envelope's own messages, read through the envelope readers — the
* `LLMMessage[]` an agent conversation IS. Roles and text, projected: no tool
* arguments, no tool results, no system prompt. That projection is stated on
* {@link TranscriptMessage} rather than implied, because a transcript that
* looked complete while silently dropping the tool leg would be a transcript
* somebody reconstructs a decision from and gets wrong.
*/
/** The wire spelling of "my sessions". */
export declare const SESSION_LIST_OP: "session-list";
/** The wire spelling of "one owned session's messages". */
export declare const SESSION_TRANSCRIPT_OP: "session-transcript";
export declare const SESSION_PENDING_OP: "session-pending";
/**
* One session-history operation, as a request carries it — the port-side shape
* behind the wire's `{ op, sessionId? }`.
*
* `op` is the port's own verb vocabulary (`list` | `transcript`), not the wire
* spelling, for the same reason the artifact request's is: the wire says
* `'session-list'` because a body field named `op` has to say which domain it
* belongs to; the port already knows.
*/
export type SessionWireRequest = {
readonly op: 'list';
} | {
readonly op: 'transcript';
readonly sessionId: string;
} | {
readonly op: 'pending';
readonly sessionId: string;
};
/** One row in a listing — everything a sidebar draws, and nothing from inside
* the conversation. */
export interface SessionSummary {
readonly sessionId: string;
/** Unix ms of the last write, straight off the stored envelope. */
readonly savedAt: number;
/** What the session IS for a listing (`listedFormatOf`): `'flowchart-v1'`
* only for a session still waiting on somebody's answer, `'conversation-v1'`
* for one that is not — a finished turn, a pause whose decision a resume
* already used (`PausedRun.answered`), or a pause no answer can reach any
* more. A sidebar that wants to badge "awaiting you" reads this and needs
* nothing else. It is not always the stored envelope's own `format`: an
* answered pause is still stored as a paused run, and `hydrate` hands it
* back as stored. */
readonly format: string;
/** How many messages the transcript would carry. A count is safe in a
* listing; content is not, so none rides here. */
readonly messageCount: number;
}
/**
* One message, projected for reading.
*
* **What is here:** the role and the text, in stored order.
*
* **What is deliberately NOT here:** tool call arguments, tool results, tool
* names and ids, and the system prompt. A transcript is what the two parties
* SAID, and the tool leg is neither — it is the run's internals, it routinely
* carries resolved credentials and raw records, and a screen that rendered it
* would be publishing the inside of the agent to whoever is signed in. The gap
* is stated because a transcript that quietly dropped a step is one somebody
* reconstructs a decision from and gets wrong: the count on
* {@link SessionSummary} counts what THIS projection carries.
*/
export interface TranscriptMessage {
readonly role: 'user' | 'assistant';
readonly content: string;
}
/** What a resolved session operation hands the reply. Exactly one arm. */
export type SessionWireResult = {
readonly op: 'pending';
readonly pending: import('./types.js').PendingAsk | null;
} | {
readonly op: 'list';
readonly sessions: readonly SessionSummary[];
} | {
readonly op: 'transcript';
readonly sessionId: string;
readonly messages: readonly TranscriptMessage[];
};
/**
* Read a session-history operation out of a request body, if the body names
* one.
*
* Returns `undefined` for a body with no `op`, and for an op that belongs to
* another domain's reader (the artifact ops) — declining is not refusing. An
* op nobody speaks raises the ONE shared refusal, so the answer is the same
* whichever reader a dialect happens to call first.
*
* Exported for custom {@link import('./httpHost.js').HttpWire} dialects.
*/
export declare function readSessionWireOp(body: Readonly<Record<string, unknown>>): SessionWireRequest | undefined;
/**
* The standard reply body for a resolved session operation:
* `{ sessions: [...] }` for a list, `{ transcript: { sessionId, messages } }`
* for a transcript.
*
* Authored once so every dialect answers byte-compatibly; a dialect that must
* add its own envelope fields spreads this and adds them beside (the managed
* runtime adds its own `status`) — the `artifactWireBody` precedent, verb for
* verb.
*/
export declare function sessionWireBody(result: SessionWireResult): Readonly<Record<string, unknown>>;