Skip to content

Commit d45a272

Browse files
committed
feat: runtime, guards, actors packages - Prompt 3
Signed-off-by: Todd Palmer <todd@betterdata.co>
1 parent 79d3e20 commit d45a272

29 files changed

Lines changed: 1262 additions & 5 deletions

packages/actors/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@loopengine/actors",
3+
"version": "0.1.0",
4+
"license": "MIT",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"module": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"import": "./dist/index.js",
12+
"require": "./dist/index.js",
13+
"types": "./dist/index.d.ts"
14+
}
15+
},
16+
"scripts": {
17+
"build": "tsc -p tsconfig.json",
18+
"typecheck": "tsc -p tsconfig.json --noEmit",
19+
"test": "vitest run"
20+
},
21+
"dependencies": {
22+
"@loopengine/core": "workspace:*"
23+
}
24+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import { describe, expect, it } from "vitest";
4+
import { actorId, transitionId, type TransitionSpec } from "@loopengine/core";
5+
import { buildActorEvidence } from "../evidence";
6+
import { canActorExecuteTransition } from "../constraints";
7+
8+
const humanTransition: TransitionSpec = {
9+
id: transitionId("approve"),
10+
from: "OPEN" as never,
11+
to: "CLOSED" as never,
12+
allowedActors: ["human"]
13+
};
14+
15+
const aiTransition: TransitionSpec = {
16+
id: transitionId("recommend"),
17+
from: "OPEN" as never,
18+
to: "IN_REVIEW" as never,
19+
allowedActors: ["human", "ai-agent"]
20+
};
21+
22+
describe("actors constraints", () => {
23+
it("Human authorized for human transition", () => {
24+
const result = canActorExecuteTransition(
25+
{ type: "human", id: actorId("user@example.com"), sessionId: "s1" },
26+
humanTransition
27+
);
28+
expect(result.authorized).toBe(true);
29+
});
30+
31+
it("AI authorized for ai-allowed transition", () => {
32+
const result = canActorExecuteTransition(
33+
{ type: "ai-agent", id: actorId("agent:icp"), agentId: "icp", gatewaySessionId: "g1" },
34+
aiTransition
35+
);
36+
expect(result.authorized).toBe(true);
37+
});
38+
39+
it("AI rejected for human-only transition (requiresApproval: true)", () => {
40+
const result = canActorExecuteTransition(
41+
{ type: "ai-agent", id: actorId("agent:icp"), agentId: "icp", gatewaySessionId: "g1" },
42+
humanTransition
43+
);
44+
expect(result.authorized).toBe(false);
45+
expect(result.requiresApproval).toBe(true);
46+
});
47+
48+
it("Circuit breaker fires after maxConsecutiveAITransitions", () => {
49+
const result = canActorExecuteTransition(
50+
{ type: "ai-agent", id: actorId("agent:icp"), agentId: "icp", gatewaySessionId: "g1" },
51+
aiTransition,
52+
{
53+
canRecommendTransitions: true,
54+
canExecuteTransitions: true,
55+
requiresHumanApprovalFor: [],
56+
maxConsecutiveAITransitions: 2,
57+
currentConsecutiveAITransitions: 2
58+
}
59+
);
60+
expect(result.authorized).toBe(false);
61+
expect(result.reason).toBe("ai_circuit_breaker");
62+
});
63+
64+
it("buildActorEvidence includes actor_type for all actor types", () => {
65+
const ev = buildActorEvidence(
66+
{ type: "automation", id: actorId("system:sync"), serviceId: "sync" },
67+
{ source: "test" }
68+
);
69+
expect(ev.actor_type).toBe("automation");
70+
});
71+
72+
it("buildActorEvidence includes ai_reasoning only for AIAgentActor", () => {
73+
const ai = buildActorEvidence(
74+
{ type: "ai-agent", id: actorId("agent:icp"), agentId: "icp", gatewaySessionId: "g1" },
75+
{ ai_reasoning: "looks good", ai_confidence: 0.9 }
76+
);
77+
const human = buildActorEvidence(
78+
{ type: "human", id: actorId("user@example.com"), sessionId: "s1" },
79+
{ ai_reasoning: "ignored" }
80+
);
81+
expect(ai.ai_reasoning).toBe("looks good");
82+
expect(human.ai_agent_id).toBeUndefined();
83+
});
84+
});

packages/actors/src/constraints.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import type { TransitionId, TransitionSpec } from "@loopengine/core";
4+
import type { Actor } from "./types";
5+
6+
export interface AIActorConstraints {
7+
canRecommendTransitions: true;
8+
canExecuteTransitions: boolean;
9+
requiresHumanApprovalFor: TransitionId[];
10+
maxConsecutiveAITransitions: number;
11+
currentConsecutiveAITransitions?: number;
12+
}
13+
14+
export function canActorExecuteTransition(
15+
actor: Actor,
16+
transition: TransitionSpec,
17+
constraints?: AIActorConstraints
18+
): { authorized: boolean; requiresApproval: boolean; reason?: string } {
19+
const typeAllowed = transition.allowedActors.includes(actor.type);
20+
if (!typeAllowed) {
21+
if (actor.type === "ai-agent") {
22+
return { authorized: false, requiresApproval: true, reason: "human_approval_required" };
23+
}
24+
return { authorized: false, requiresApproval: false, reason: "unauthorized_actor" };
25+
}
26+
27+
if (actor.type !== "ai-agent") {
28+
return { authorized: true, requiresApproval: false };
29+
}
30+
31+
if (!constraints) {
32+
return { authorized: true, requiresApproval: false };
33+
}
34+
if (!constraints.canExecuteTransitions) {
35+
return { authorized: false, requiresApproval: true, reason: "human_approval_required" };
36+
}
37+
if (constraints.requiresHumanApprovalFor.includes(transition.id)) {
38+
return { authorized: false, requiresApproval: true, reason: "human_approval_required" };
39+
}
40+
if (
41+
typeof constraints.currentConsecutiveAITransitions === "number" &&
42+
constraints.currentConsecutiveAITransitions >= constraints.maxConsecutiveAITransitions
43+
) {
44+
return { authorized: false, requiresApproval: true, reason: "ai_circuit_breaker" };
45+
}
46+
return { authorized: true, requiresApproval: false };
47+
}

packages/actors/src/evidence.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import type { Evidence } from "@loopengine/core";
4+
import type { Actor } from "./types";
5+
6+
export function buildActorEvidence(actor: Actor, baseEvidence: Evidence): Evidence {
7+
const merged: Evidence = {
8+
...baseEvidence,
9+
actor_type: actor.type,
10+
actor_id: actor.id
11+
};
12+
if (actor.type === "ai-agent") {
13+
merged.ai_agent_id = actor.agentId;
14+
if ("ai_confidence" in baseEvidence) {
15+
merged.ai_confidence = baseEvidence.ai_confidence;
16+
}
17+
if ("ai_reasoning" in baseEvidence) {
18+
merged.ai_reasoning = baseEvidence.ai_reasoning;
19+
}
20+
}
21+
return merged;
22+
}

packages/actors/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
export * from "./types";
4+
export * from "./constraints";
5+
export * from "./evidence";

packages/actors/src/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import type { ActorRef, ActorType, AggregateId, Evidence, LoopId, TransitionId } from "@loopengine/core";
4+
5+
export interface HumanActor extends ActorRef {
6+
type: "human";
7+
sessionId: string;
8+
}
9+
10+
export interface AutomationActor extends ActorRef {
11+
type: "automation";
12+
serviceId: string;
13+
}
14+
15+
export interface AIAgentActor extends ActorRef {
16+
type: "ai-agent";
17+
agentId: string;
18+
gatewaySessionId: string;
19+
recommendedBy?: string;
20+
}
21+
22+
export interface WebhookActor extends ActorRef {
23+
type: "webhook";
24+
source: string;
25+
}
26+
27+
export interface SystemActor extends ActorRef {
28+
type: "system";
29+
}
30+
31+
export type Actor = HumanActor | AutomationActor | AIAgentActor | WebhookActor | SystemActor;
32+
33+
export interface AIActorSubmission {
34+
actor: AIAgentActor;
35+
loopId: LoopId;
36+
aggregateId: AggregateId;
37+
recommendedTransition: TransitionId;
38+
confidence: number;
39+
reasoning: string;
40+
evidence: Evidence;
41+
}
42+
43+
export type { ActorType };

packages/actors/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"rootDir": "src",
5+
"outDir": "dist"
6+
},
7+
"include": ["src/**/*"]
8+
}

packages/core/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ export interface LoopDefinition {
105105
metadata?: Record<string, unknown>;
106106
}
107107

108+
export interface LoopRegistry {
109+
get(loopId: LoopId): LoopDefinition | undefined;
110+
list(domain?: string): LoopDefinition[];
111+
}
112+
108113
export interface ActorRef {
109114
type: ActorType;
110115
id: ActorId;

packages/events/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@loopengine/events",
3+
"version": "0.1.0",
4+
"license": "MIT",
5+
"type": "module",
6+
"main": "./dist/index.js",
7+
"module": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"import": "./dist/index.js",
12+
"require": "./dist/index.js",
13+
"types": "./dist/index.d.ts"
14+
}
15+
},
16+
"scripts": {
17+
"build": "tsc -p tsconfig.json",
18+
"typecheck": "tsc -p tsconfig.json --noEmit"
19+
},
20+
"dependencies": {
21+
"@loopengine/core": "workspace:*"
22+
}
23+
}

packages/events/src/index.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// @license MIT
2+
// SPDX-License-Identifier: MIT
3+
import type {
4+
ActorRef,
5+
AggregateId,
6+
CorrelationId,
7+
Evidence,
8+
GuardId,
9+
LoopId,
10+
OutcomeId,
11+
StateId,
12+
TransitionId
13+
} from "@loopengine/core";
14+
15+
export interface LoopEventBase {
16+
eventId: string;
17+
loopId: LoopId;
18+
aggregateId: AggregateId;
19+
orgId: string;
20+
occurredAt: string;
21+
correlationId: CorrelationId;
22+
causationId?: string;
23+
}
24+
25+
export interface LoopStartedEvent extends LoopEventBase {
26+
type: "loop.started";
27+
initialState: StateId;
28+
actor: ActorRef;
29+
}
30+
31+
export interface TransitionExecutedEvent extends LoopEventBase {
32+
type: "loop.transition.executed";
33+
fromState: StateId;
34+
toState: StateId;
35+
transitionId: TransitionId;
36+
actor: ActorRef;
37+
evidence: Evidence;
38+
durationMs?: number;
39+
}
40+
41+
export interface GuardFailedEvent extends LoopEventBase {
42+
type: "loop.guard.failed";
43+
fromState: StateId;
44+
attemptedTransitionId: TransitionId;
45+
guardId: GuardId;
46+
guardFailureMessage: string;
47+
severity?: "hard" | "soft";
48+
actor: ActorRef;
49+
}
50+
51+
export interface LoopCompletedEvent extends LoopEventBase {
52+
type: "loop.completed";
53+
terminalState: StateId;
54+
actor: ActorRef;
55+
durationMs: number;
56+
transitionCount: number;
57+
outcomeId: OutcomeId;
58+
valueUnit: string;
59+
}
60+
61+
export type LoopEvent =
62+
| LoopStartedEvent
63+
| TransitionExecutedEvent
64+
| GuardFailedEvent
65+
| LoopCompletedEvent;
66+

0 commit comments

Comments
 (0)