Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/agentstack/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ Backed by `src/index.ts`, `src/types.ts`, `src/manifest.ts`:
- **Status lifecycle** — `pending → queued → running → blocked → complete | failed | cancelled`.
- **`AgentStack` coordinator** (in-memory `Map` storage, injectable clock):
`registerAgent`, `getAgent`, `createTask`, `getTask`, `assignTask`, `updateTaskStatus`,
`delegate`, `revokeDelegation`, `listTasks`, `snapshot`, and an `on(listener)` event subscription.
`delegate`, `revokeDelegation`, `listDelegations`, `hasDelegation`, `listTasks`, `snapshot`,
and an `on(listener)` event subscription.
- **Events** — `agent.registered`, `task.created`, `task.assigned`, `task.updated`,
`delegation.granted`, `delegation.revoked`.
- **`agentStackPlugin`** — a LogicSRC `PluginDefinition`, plus `agentStackManifest` declaring
Expand Down
7 changes: 6 additions & 1 deletion packages/agentstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Shared AppKit OpenSpec (`profullstack-web/openspec/specs/agentstack`).
(`pending → queued → running → blocked → complete | failed | cancelled`) that can bind to
payment, escrow, and reputation events.
- **`AgentStack`** — an in-memory coordinator that registers agents, tracks tasks, records
delegation grants, and emits coordination events. Storage backends can wrap the same API.
delegation grants, checks active scoped authority, and emits coordination events. Storage
backends can wrap the same API.
- **`agentStackPlugin`** — a LogicSRC `PluginDefinition` (validated against the plugin
manifest schema) exposing AgentStack as a coordination plugin with routes, events,
permissions, and a TUI panel.
Expand All @@ -39,6 +40,10 @@ const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title:
stack.assignTask(task.id, agentDid("abc"));
stack.updateTaskStatus(task.id, "running");
stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" });

const grant = stack.delegate(owner, agentDid("abc"), ["tasks:create"]);
stack.hasDelegation(owner, agentDid("abc"), "tasks:create"); // true while the grant is active
stack.revokeDelegation(grant.id);
```

## Cross-app identity
Expand Down
41 changes: 32 additions & 9 deletions packages/agentstack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,38 @@ describe("AgentStack coordinator", () => {
it("records delegation grants and emits events", () => {
const stack = new AgentStack();
const listener = vi.fn();
stack.on(listener);
stack.registerAgent(agent);
const grant = stack.delegate(owner, agent.did, ["tasks:create"]);

expect(grant.ownerDid).toBe(owner);
expect(grant.agentDid).toBe(agent.did);
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" }));
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" }));
});
stack.on(listener);
stack.registerAgent(agent);
const grant = stack.delegate(owner, agent.did, ["tasks:create"]);

expect(grant.ownerDid).toBe(owner);
expect(grant.agentDid).toBe(agent.did);
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" }));
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" }));
});

it("checks active delegation authority by owner, agent, scope, and expiry", () => {
const stack = new AgentStack(() => "2026-01-01T00:00:00.000Z");
stack.registerAgent(agent);

stack.delegate(owner, agent.did, ["tasks:create"], "2026-01-02T00:00:00.000Z");

expect(stack.hasDelegation(owner, agent.did, "tasks:create")).toBe(true);
expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(false);
expect(stack.hasDelegation(owner, agent.did, "tasks:create", "2026-01-03T00:00:00.000Z")).toBe(false);
expect(stack.listDelegations({ ownerDid: owner, agentDid: agent.did, scope: "tasks:create" })).toHaveLength(1);
expect(stack.listDelegations({ activeAt: "2026-01-03T00:00:00.000Z" })).toHaveLength(0);
});

it("allows wildcard delegation scopes and ignores revoked grants", () => {
const stack = new AgentStack();
stack.registerAgent(agent);
const grant = stack.delegate(owner, agent.did, ["*"]);

expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(true);
stack.revokeDelegation(grant.id);
expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(false);
});

it("rejects unknown agents and invalid DIDs", () => {
const stack = new AgentStack();
Expand Down
68 changes: 52 additions & 16 deletions packages/agentstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,18 +178,46 @@ export class AgentStack {
return grant;
}

revokeDelegation(grantId: string): DelegationGrant {
const grant = this.delegations.get(grantId);
if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`);
this.delegations.delete(grantId);
this.emit({ type: "delegation.revoked", grant });
return grant;
}

listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] {
return [...this.tasks.values()].filter((task) => {
if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false;
if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false;
revokeDelegation(grantId: string): DelegationGrant {
const grant = this.delegations.get(grantId);
if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`);
this.delegations.delete(grantId);
this.emit({ type: "delegation.revoked", grant });
return grant;
}

listDelegations(filter?: {
ownerDid?: string;
agentDid?: string;
scope?: string;
activeAt?: string;
}): DelegationGrant[] {
return [...this.delegations.values()].filter((grant) => {
if (filter?.ownerDid && grant.ownerDid !== filter.ownerDid) return false;
if (filter?.agentDid && grant.agentDid !== filter.agentDid) return false;
if (filter?.scope && !this.grantAllowsScope(grant, filter.scope)) return false;
if (filter?.activeAt && !this.grantIsActive(grant, filter.activeAt)) return false;
return true;
});
}

hasDelegation(ownerDidValue: string, agentDidValue: string, scope: string, at: string = this.now()): boolean {
if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`);
if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`);
return (
this.listDelegations({
ownerDid: ownerDidValue,
agentDid: agentDidValue,
scope,
activeAt: at
}).length > 0
);
}

listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] {
return [...this.tasks.values()].filter((task) => {
if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false;
if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false;
if (filter?.status && task.status !== filter.status) return false;
return true;
});
Expand All @@ -205,10 +233,18 @@ export class AgentStack {

private requireTask(id: string): DidTask {
const task = this.tasks.get(id);
if (!task) throw new Error(`Unknown task: ${id}`);
return task;
}
}
if (!task) throw new Error(`Unknown task: ${id}`);
return task;
}

private grantAllowsScope(grant: DelegationGrant, scope: string): boolean {
return grant.scopes.includes(scope) || grant.scopes.includes("*");
}

private grantIsActive(grant: DelegationGrant, at: string): boolean {
return !grant.expiresAt || grant.expiresAt > at;
}
}

/** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */
export const agentStackPlugin: PluginDefinition = {
Expand Down