@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
207 lines • 9.77 kB
TypeScript
import type { LucidClientLike, LucidRawRunner } from '../stores/lucid.js';
import type { Actor } from '../types.js';
import type { PoppyContext, PoppyEvent, PoppyEventBody, PoppyResponder, PoppyStatus } from './protocol.js';
/**
* A Poppy conversation as this surface keeps it. It belongs to a Personal Agent (`clientId`) and
* one of its Users (`userId`); once it used an account here, to that account too (`accountRef`,
* §7.2). The agent side of it is an agent thread (`threadId`) — a Direct Conversation shares its
* parent's thread, which is how the Company Agent knows what was said in either (§7.10).
*/
export interface PoppyConversationRecord {
id: string;
clientId: string;
userId: string;
accountRef: string | null;
agentName: string;
threadId: string | null;
/** Set on a Direct Conversation: the conversation it was opened from. */
parentId: string | null;
/** On a parent: its open Direct Conversation. */
openDirectId: string | null;
status: PoppyStatus;
responder: PoppyResponder;
/** The User's context as last shared — fields a message leaves out keep their value (§7.4). */
context: PoppyContext;
/** The run whose reply is being written, while there is one. */
activeRunId: string | null;
/**
* The position of the last message event a turn has taken up. The event log IS the turn queue:
* user messages after it are what the next turn answers — on whichever replica picks it up.
*/
turnSeq: number;
/** Who the latest request came as — what the next turn runs as. */
grant: PoppyGrant | null;
/** Events at or below this sequence number were pruned: a cursor there is `cursor_expired`. */
prunedSeq: number;
createdAt: number;
updatedAt: number;
}
/** The identity a conversation's turns run as: that of its latest request (§7.11). */
export interface PoppyGrant {
actor: Actor;
/** The token's account scopes; empty when signed out. */
scopes: string[];
signedIn: boolean;
}
export type PoppyConversationPatch = Partial<Pick<PoppyConversationRecord, 'accountRef' | 'threadId' | 'openDirectId' | 'status' | 'responder' | 'context' | 'activeRunId' | 'turnSeq' | 'grant' | 'prunedSeq'>>;
/** An event with its position in the conversation. */
export interface StoredPoppyEvent {
seq: number;
event: PoppyEvent;
}
/** What an accepted message keeps for its retries (§7.3). */
export interface PoppyMessageReceipt {
fingerprint: string;
conversationId: string;
/** The HTTP status and body the first delivery got. */
response: {
status: number;
body: Record<string, unknown>;
};
}
export type PoppyMessageClaim = {
status: 'claimed';
} | ({
status: 'existing';
} & PoppyMessageReceipt);
/**
* Where conversations, their events and message receipts live. Events are append-only and read by
* position, so a cursor replays exactly what came after it, on any replica.
*
* @experimental Tracks Personal Agent Protocol Draft 0.1 (https://personalagentprotocol.org/docs/spec),
* a spec still in development: this API WILL change as the spec evolves — possibly in breaking
* ways, outside semver majors while it is a draft.
*/
export interface PoppyStore {
createConversation(record: PoppyConversationRecord): Promise<void>;
getConversation(id: string): Promise<PoppyConversationRecord | null>;
updateConversation(id: string, patch: PoppyConversationPatch): Promise<void>;
/**
* Append an event, numbered after the conversation's last. With `key`, at most one event per key
* is ever appended: a second append answers the first event with `created: false` — what keeps a
* re-read turn from writing its reply twice.
*/
appendEvent(conversationId: string, body: PoppyEventBody, options?: {
key?: string;
}): Promise<{
event: StoredPoppyEvent;
created: boolean;
}>;
/** Events after `afterSeq`, in order, at most `limit`. */
listEvents(conversationId: string, afterSeq: number, limit: number): Promise<StoredPoppyEvent[]>;
/** The position of an event, `null` when the conversation has no such event (any more). */
findEventSeq(conversationId: string, eventId: string): Promise<number | null>;
/** Drop the events created before `before` (epoch ms), keeping at least the last one. */
pruneEvents(conversationId: string, before: number): Promise<void>;
/** Claim `messageId` for `owner` — atomically, so two deliveries of one message are one. */
claimMessage(owner: string, messageId: string, receipt: PoppyMessageReceipt): Promise<PoppyMessageClaim>;
/** Drop a claim whose message could not be taken, so its retry is processed afresh. */
releaseMessage(owner: string, messageId: string): Promise<void>;
/**
* Take (or renew) the right to write the reply of the conversation's active run for `ttlMs` —
* `false` while another holder's lease is live. What lets any replica pick up a reply whose
* writer died, without two writing it at once.
*/
leaseReader(conversationId: string, holder: string, ttlMs: number): Promise<boolean>;
releaseReader(conversationId: string, holder: string): Promise<void>;
}
/** `evt_<seq in base 36>_<random>`: unique, and its position survives the event being pruned. */
export declare function newEventId(seq: number): string;
/** The position an event id was minted at, or `null` for an id this surface never minted. */
export declare function eventIdSeq(id: string): number | null;
/** Process-local {@link PoppyStore} — tests and single-instance dev. */
export declare class InMemoryPoppyStore implements PoppyStore {
#private;
createConversation(record: PoppyConversationRecord): Promise<void>;
getConversation(id: string): Promise<PoppyConversationRecord | null>;
updateConversation(id: string, patch: PoppyConversationPatch): Promise<void>;
appendEvent(conversationId: string, body: PoppyEventBody, options?: {
key?: string;
}): Promise<{
event: {
seq: number;
event: PoppyEvent;
};
created: boolean;
}>;
listEvents(conversationId: string, afterSeq: number, limit: number): Promise<{
seq: number;
event: PoppyEvent;
}[]>;
findEventSeq(conversationId: string, eventId: string): Promise<number | null>;
pruneEvents(conversationId: string, before: number): Promise<void>;
claimMessage(owner: string, messageId: string, receipt: PoppyMessageReceipt): Promise<{
fingerprint: string;
conversationId: string;
/** The HTTP status and body the first delivery got. */
response: {
status: number;
body: Record<string, unknown>;
};
status: 'existing';
} | {
status: 'claimed';
}>;
releaseMessage(owner: string, messageId: string): Promise<void>;
leaseReader(conversationId: string, holder: string, ttlMs: number): Promise<boolean>;
releaseReader(conversationId: string, holder: string): Promise<void>;
}
export declare const POPPY_TABLES: {
readonly conversations: 'agent_poppy_conversation';
readonly events: 'agent_poppy_event';
readonly messages: 'agent_poppy_message';
};
/**
* `CREATE TABLE IF NOT EXISTS` DDL for the Poppy tables — the agent tables' portable dialect
* (quoted identifiers, epoch-ms `BIGINT`, `TEXT` JSON; `forDialect` adapts it to MySQL). An event's
* primary key is its position, so two appends racing for one position cannot both win; a message's
* is its (owner, id), the idempotency lock of §7.3.
*/
export declare function poppyTableStatements(): string[];
/** Create the Poppy tables if missing. Idempotent. */
export declare function ensurePoppyTables(db: LucidRawRunner): Promise<void>;
export interface LucidPoppyStoreOptions {
/** Create the tables on first use (`CREATE TABLE IF NOT EXISTS`). Default `true`. */
autoCreateTables?: boolean;
}
/** {@link PoppyStore} over Lucid (any of SQLite / Postgres / MySQL). */
export declare class LucidPoppyStore implements PoppyStore {
#private;
private readonly db;
constructor(db: LucidClientLike & LucidRawRunner, options?: LucidPoppyStoreOptions);
/** Provision the tables (once). A failure is retried on the next call. */
ready(): Promise<void>;
createConversation(record: PoppyConversationRecord): Promise<void>;
getConversation(id: string): Promise<PoppyConversationRecord | null>;
updateConversation(id: string, patch: PoppyConversationPatch): Promise<void>;
appendEvent(conversationId: string, body: PoppyEventBody, options?: {
key?: string;
}): Promise<{
event: {
seq: number;
event: PoppyEvent;
};
created: boolean;
}>;
listEvents(conversationId: string, afterSeq: number, limit: number): Promise<{
seq: number;
event: PoppyEvent;
}[]>;
findEventSeq(conversationId: string, eventId: string): Promise<number | null>;
pruneEvents(conversationId: string, before: number): Promise<void>;
claimMessage(owner: string, messageId: string, receipt: PoppyMessageReceipt): Promise<{
status: 'claimed';
fingerprint?: never;
conversationId?: never;
response?: never;
} | {
status: 'existing';
fingerprint: string;
conversationId: string;
response: PoppyMessageReceipt['response'];
}>;
releaseMessage(owner: string, messageId: string): Promise<void>;
leaseReader(conversationId: string, holder: string, ttlMs: number): Promise<boolean>;
releaseReader(conversationId: string, holder: string): Promise<void>;
}
//# sourceMappingURL=store.d.ts.map