loopengine 0.0.18 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/PROTOCOL.md +245 -0
  2. package/README.md +44 -5
  3. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  4. package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
  5. package/dist/{cli.js → bin/cli.js} +131 -16
  6. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  7. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  8. package/dist/core/client.d.ts +113 -0
  9. package/dist/core/client.js +123 -0
  10. package/dist/core/discover-agents.d.ts +45 -0
  11. package/dist/{discover-agents.js → core/discover-agents.js} +11 -3
  12. package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
  13. package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
  14. package/dist/core/loop-events.d.ts +175 -0
  15. package/dist/core/loop-events.js +1 -0
  16. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  18. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  19. package/dist/{model-calls → core/model-calls}/openai-model-call.js +1 -1
  20. package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
  21. package/dist/{run-agent.js → core/run-agent.js} +242 -60
  22. package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
  23. package/dist/{session-store.js → core/session-store.js} +19 -0
  24. package/dist/core/system-tools/ask_user.d.ts +72 -0
  25. package/dist/core/system-tools/ask_user.js +136 -0
  26. package/dist/core/system-tools/index.d.ts +3 -0
  27. package/dist/core/system-tools/index.js +12 -0
  28. package/dist/core/system-tools/read_file.d.ts +28 -0
  29. package/dist/core/system-tools/read_file.js +64 -0
  30. package/dist/{vector-index.js → core/vector-index.js} +1 -1
  31. package/dist/index.d.ts +19 -16
  32. package/dist/index.js +16 -9
  33. package/package.json +34 -28
  34. package/protocol/loop-event.schema.json +257 -0
  35. package/dist/discover-agents.d.ts +0 -24
  36. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  37. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  38. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  39. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  40. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
@@ -0,0 +1,175 @@
1
+ import type { CheckResult } from './budget.js';
2
+ import type { Decision, PendingApproval } from 'actauth';
3
+ import type { PendingQuestion } from './system-tools/index.js';
4
+ /** BudgetTracker's own read-only budget check, run once per loop turn
5
+ * before the model call — reported unchanged (spread, not renamed) so
6
+ * this never drifts from whatever budget.ts's own CheckResult actually
7
+ * contains. */
8
+ export type BudgetCheckEvent = {
9
+ type: 'budget:check';
10
+ } & CheckResult;
11
+ /** compaction.ts's own recovery kicked in because the prompt was rejected as
12
+ * too large — `from`/`to` are message-array lengths (this turn's own
13
+ * newMessages included), not token counts; see run-agent.ts's own
14
+ * recovery.call onPromptTooLong hook. Named after *what triggered it and
15
+ * what happened* (the prompt was too long, so it got compacted), not
16
+ * after which module did the work — "compaction:recover" still didn't
17
+ * say this only ever fires for the prompt-too-long case specifically,
18
+ * same problem contextclip:check's own rename from a package name was
19
+ * fixing in the first place.
20
+ *
21
+ * No sibling event for recovery.ts's other two failure modes
22
+ * (media-too-large, truncated output) — neither has an onMediaTooLarge/
23
+ * onTruncated hook actually wired up in run-agent.ts today, so neither
24
+ * can currently fire at all. A prior 'recovery:summary' aggregate event
25
+ * covering all three was removed for being ~100% redundant with this one
26
+ * while that's true; it can come back once a second real recovery type
27
+ * exists to aggregate. */
28
+ export interface PromptCompactionEvent {
29
+ type: 'prompt:compaction';
30
+ from: number;
31
+ to: number;
32
+ }
33
+ /** The model's own "I'll do X" preamble alongside a tool_use request —
34
+ * the only *live* signal of assistant text mid-turn (the final answer
35
+ * only ever arrives via 'loop:done'/'done', once, at the very end). */
36
+ export interface AssistantTextEvent {
37
+ type: 'assistant:text';
38
+ text: string;
39
+ }
40
+ /** One ActAuth Gate.evaluate() outcome per requested tool call — 'allow'
41
+ * here can still mean a human was asked interactively and said yes (see
42
+ * run-agent.ts's own wasAskedInteractively); this event fires regardless,
43
+ * the interactive case just also gets its own approval:pending. */
44
+ export interface ActauthDecisionEvent {
45
+ type: 'actauth:decision';
46
+ tool: string;
47
+ decision: Decision;
48
+ reason: string;
49
+ }
50
+ /** A tool call's decision is in and it's about to run (or, for a system
51
+ * tool, already bypassed the gate entirely) — fired the instant that's
52
+ * known, not once execution finishes, so a live caller can show
53
+ * "Running…" immediately instead of a silent gap. Matched to its later
54
+ * 'tool:result' by `id` (the model's own tool_use id). */
55
+ export interface ToolStartedEvent {
56
+ type: 'tool:started';
57
+ id: string;
58
+ tool: string;
59
+ args: Record<string, unknown>;
60
+ detailText: string;
61
+ }
62
+ /** A tool call's resolution — auto-allowed, interactively approved,
63
+ * denied, or skipped alike. `args`/`detailText` are omitted for some
64
+ * skipped/denied paths that never had them resolved (see run-agent.ts's
65
+ * own call sites). */
66
+ export interface ToolResultEvent {
67
+ type: 'tool:result';
68
+ id: string;
69
+ tool: string;
70
+ args?: Record<string, unknown>;
71
+ detailText?: string;
72
+ statusText: string;
73
+ }
74
+ /** ToolLane's own raw fulfil/reject outcome, logged alongside (not
75
+ * instead of) the richer 'tool:result' above — kept as its own event
76
+ * since it's toollane's own vocabulary (`summary`, not `statusText`), not
77
+ * reshaped to match. */
78
+ export interface ToolLaneResultEvent {
79
+ type: 'toollane:result';
80
+ name: string;
81
+ summary: string;
82
+ }
83
+ /** A `Skill` tool_use block was resolved to a real skill body via
84
+ * SkillGarden's own lazy "index-now-load-later" design — not gated by
85
+ * ActAuth (see run-agent.ts's own comment on why Skill invocation isn't
86
+ * a real tool call). Named after what happened (the skill's full body
87
+ * got loaded), not after which library method was called — same
88
+ * reasoning budget:check's own rename from contextclip:check used. */
89
+ export interface SkillLoadEvent {
90
+ type: 'skill:loaded';
91
+ skill: string;
92
+ }
93
+ /** An approved call never ran because a sibling call in the same turn's
94
+ * batch was denied — the whole batch cancels together (see
95
+ * RunAgentResult.stopReason's own doc comment). */
96
+ export interface LoopSkippedEvent {
97
+ type: 'loop:skipped';
98
+ name: string;
99
+ deniedTools: string[];
100
+ }
101
+ /** Hit config.maxTurns without the model producing a final answer. */
102
+ export interface LoopMaxTurnsEvent {
103
+ type: 'loop:max_turns';
104
+ maxTurns: number;
105
+ }
106
+ /** A human denied at least one requested tool call this turn — the whole
107
+ * turn stops right there (see RunAgentResult.stopReason). */
108
+ export interface LoopDeniedEvent {
109
+ type: 'loop:denied';
110
+ deniedTools: string[];
111
+ }
112
+ /** The loop's own final answer — the model produced text with no more
113
+ * tool_use blocks. Distinct from the adapter-level 'done' below: this
114
+ * only ever means a genuine finish, never a synthetic max_turns/denied
115
+ * notice (see adapters/http.ts's own 'done', which covers all three). */
116
+ export interface LoopDoneEvent {
117
+ type: 'loop:done';
118
+ text: string;
119
+ }
120
+ /** Every event runAgent() itself can emit via RunAgentOptions.onEvent —
121
+ * not the adapter-synthesized ones below, which no caller of runAgent()
122
+ * itself ever produces. */
123
+ export type RunAgentLoopEvent = BudgetCheckEvent | PromptCompactionEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillLoadEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | LoopDoneEvent;
124
+ /** adapters/http.ts echoing back which session id this turn used — first
125
+ * event of every streamed turn, so a caller that omitted sessionId can
126
+ * capture what got generated. Not something runAgent() itself knows
127
+ * (session ids are resolved per-request by the adapter, before runAgent
128
+ * is even called). */
129
+ export interface SessionEvent {
130
+ type: 'session';
131
+ sessionId: string;
132
+ }
133
+ /** A tool call needs a human 'ask' decision, live — spreads actauth's own
134
+ * PendingApproval verbatim (id/tool/args/scope/reason/requestedAt)
135
+ * alongside `type`, so this never drifts from whatever that package
136
+ * actually returns. */
137
+ export type ApprovalPendingEvent = {
138
+ type: 'approval:pending';
139
+ } & PendingApproval;
140
+ /** The system ask_user tool raised a genuinely ambiguous question — same
141
+ * spread-verbatim reasoning as ApprovalPendingEvent above, against
142
+ * system-tools/ask_user.ts's own PendingQuestion. */
143
+ export type QuestionPendingEvent = {
144
+ type: 'question:pending';
145
+ } & PendingQuestion;
146
+ /** The turn is over — covers a genuine finish and both synthetic
147
+ * stopReasons (max_turns/denied) alike, the one event every caller can
148
+ * treat as "nothing more is coming" regardless of which of the three it
149
+ * was (see RunAgentResult.stopReason's own doc comment). */
150
+ export interface DoneEvent {
151
+ type: 'done';
152
+ text: string;
153
+ stopReason?: 'max_turns' | 'denied';
154
+ }
155
+ /** Something failed after the response was already committed (SSE
156
+ * headers sent, or a background turn already running) — surfaced in-band
157
+ * rather than as an HTTP status code, since it's too late for one. */
158
+ export interface ErrorEvent {
159
+ type: 'error';
160
+ error: string;
161
+ }
162
+ /** Synthesized by adapters/http.ts around a turn — session ids and
163
+ * HTTP-level errors aren't things runAgent() itself produces, but they're
164
+ * still part of the same lifecycle a caller observes end to end. */
165
+ export type AdapterLoopEvent = SessionEvent | ApprovalPendingEvent | QuestionPendingEvent | DoneEvent | ErrorEvent;
166
+ /** The full wire vocabulary — what run-agent.ts emits via onEvent, plus
167
+ * what adapters/http.ts (and, in --json mode, adapters/cli.ts) layers
168
+ * around it. This is "the standard output" every channel adapter is a
169
+ * projection of: adapters/http.ts's SSE stream writes one of these per
170
+ * frame; its plain /messages route collects the whole array into an
171
+ * `events` response field; adapters/cli.ts's --json mode writes one per
172
+ * NDJSON line; web/playground.ts is just a UI rendering whichever of
173
+ * those two HTTP routes it's talking to. */
174
+ export type LoopEvent = RunAgentLoopEvent | AdapterLoopEvent;
175
+ export type LoopEventType = LoopEvent['type'];
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import type { ModelCall } from '#run-agent.js';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
3
  export interface AnthropicModelCallOptions {
4
4
  /** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
5
5
  apiKey?: string;
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { ModelCall } from '#run-agent.js';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
3
  export interface DeepSeekModelCallOptions {
4
4
  /** Defaults to the DEEPSEEK_API_KEY env var. */
5
5
  apiKey?: string;
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { Message, ModelCall, ModelResponse } from '#run-agent.js';
2
+ import type { Message, ModelCall, ModelResponse } from '#core/run-agent.js';
3
3
  type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
4
4
  export interface OpenAIModelCallOptions {
5
5
  /** Defaults to the OPENAI_API_KEY env var, same as the SDK itself. */
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // stop_reason is normalized to the vocabulary anthropic-model-call.ts
15
15
  // already established ('end_turn' / 'tool_use' / 'max_tokens') rather than
16
- // passed through as OpenAI's own finish_reason strings — reflowkit's
16
+ // passed through as OpenAI's own finish_reason strings — recovery.ts's
17
17
  // defaultIsTruncated checks a translated ModelResponse for stop_reason ===
18
18
  // 'max_tokens', so a provider-agnostic caller wiring up onTruncated later
19
19
  // gets the same behavior regardless of which ModelCall is plugged in.
@@ -1,5 +1,11 @@
1
- import { RuleSet } from 'actauth';
2
- import type { AgentConfig, ToolDefinition, ToolSchema } from '#agent-config.js';
1
+ import { RuleSet, type Approver } from 'actauth';
2
+ import type { AgentConfig, ToolDefinition, ToolSchema } from '#core/agent-config.js';
3
+ import { type PendingQuestion } from './system-tools/index.js';
4
+ import type { LoopEvent } from './loop-events.js';
5
+ export { systemTools } from './system-tools/index.js';
6
+ export type * from './loop-events.js';
7
+ export declare const systemSkillsDir: string;
8
+ export declare function dedupeToolsByName(list: ToolDefinition[]): ToolDefinition[];
3
9
  export interface ModelContentBlock {
4
10
  type: string;
5
11
  /** text blocks. */
@@ -16,13 +22,20 @@ export interface ModelContentBlock {
16
22
  content?: string;
17
23
  /** tool_result blocks: true if the tool call failed or was denied. */
18
24
  is_error?: boolean;
25
+ /** tool_result blocks: the actauth decision's own reason this call was
26
+ * allowed (e.g. "matched rule '...'"). Extra metadata riding alongside
27
+ * `content`, not folded into it — every model-calls/* adapter
28
+ * explicitly whitelists which fields it forwards to a real model API,
29
+ * so this never reaches the model, only a caller (the playground)
30
+ * inspecting stored history directly. */
31
+ reason?: string;
19
32
  }
20
33
  export interface ModelResponse {
21
34
  stop_reason: string;
22
35
  content: ModelContentBlock[];
23
36
  }
24
37
  /** loopengine's own conversation-message type — a superset of
25
- * ContextClip's `{role, content: string}`: `content` can also be a
38
+ * budget.ts's own `{role, content: string}`: `content` can also be a
26
39
  * `ModelContentBlock[]`, the same block shape `ModelResponse.content`
27
40
  * already used, so a model's tool_use requests and this loop's
28
41
  * tool_result replies round-trip with real per-call identity
@@ -37,8 +50,10 @@ export interface Message {
37
50
  /** Swap the Anthropic SDK in here — this is the only seam a real API call needs. */
38
51
  export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[]) => Promise<ModelResponse>;
39
52
  export interface RunAgentOptions {
40
- /** Emitted at each loop step for callers that want visibility (logging, UI, tests). Default: no-op. */
41
- onEvent?: (event: string, detail: unknown) => void;
53
+ /** Emitted at each loop step for callers that want visibility (logging, UI, tests) —
54
+ * see loop-events.ts's own header comment for why this is a single typed
55
+ * union rather than a string name plus untyped detail. Default: no-op. */
56
+ onEvent?: (event: LoopEvent) => void;
42
57
  /** The ActAuth tenant this call runs as — feeds the Gate's scope, so it
43
58
  * governs which rules apply. runAgent() never sees a request, so it
44
59
  * can't call AgentConfig.tenantFor itself; adapters/http.ts resolves it
@@ -46,6 +61,45 @@ export interface RunAgentOptions {
46
61
  * omitting it — every standalone/CLI caller (which never has a request
47
62
  * to resolve tenantFor from) gets that default automatically. */
48
63
  tenant?: string;
64
+ /** This call's own session id (the *raw* one — see adapters/http.ts's
65
+ * own rawSessionId/storageSessionId distinction, this is the former),
66
+ * if the caller has one. runAgent() never sees a request or a
67
+ * SessionStore itself, so it can't derive this on its own — passed
68
+ * through purely so a pending ask_user question can be tagged with
69
+ * which session raised it (see system-tools/ask_user.ts's own
70
+ * PendingQuestion.sessionId), letting a caller list/answer questions
71
+ * scoped to one conversation instead of every one this process has ever
72
+ * seen. Omitted (a standalone script, most CLI usage) just means a
73
+ * pending question can't be session-scoped — still fine, it's still
74
+ * agent-scoped either way. */
75
+ sessionId?: string;
76
+ /** Default Approver for this call's 'ask' decisions, when
77
+ * AgentConfig.approver isn't set — the seam an adapter uses to pick
78
+ * whichever approver actually fits its own channel (ConsoleApprover for
79
+ * a real terminal, a web-based one for an HTTP-served conversation,
80
+ * ...) without every agent needing to hardcode one itself. An explicit
81
+ * AgentConfig.approver still wins outright over this when the agent
82
+ * author set one on purpose — same as it always has (see Gate
83
+ * construction below) — this only fills in when that's absent. Default,
84
+ * if neither is given: actauth's own ConsoleApprover, same as always. */
85
+ approver?: Approver;
86
+ /** Fired the instant the system ask_user tool registers a new pending
87
+ * question — this is the seam that decides between a real, answerable
88
+ * pending question (something adapters/http.ts's own /questions REST
89
+ * endpoints, or an SSE push, can resolve later) and a blocking terminal
90
+ * prompt with nowhere else to go (see system-tools/ask_user.ts's own
91
+ * promptOnConsole).
92
+ *
93
+ * Deliberately its own option, not inferred from onEvent above —
94
+ * onEvent means "please tell me about loop events" (adapters/cli.ts
95
+ * passes one purely to log them), not "I have a way to actually answer
96
+ * a question later." Conflating the two was a real bug: it made a
97
+ * plain `loopengine run` hang forever registering an unanswerable
98
+ * question instead of just prompting in the terminal it's already
99
+ * attached to — confirmed live before this existed. Only an adapter
100
+ * that genuinely has an answering mechanism (adapters/http.ts, for both
101
+ * its routes) should pass this. */
102
+ onQuestionPending?: (question: PendingQuestion) => void;
49
103
  }
50
104
  export interface RunAgentResult {
51
105
  /** Final assistant text for this turn. */
@@ -55,17 +109,31 @@ export interface RunAgentResult {
55
109
  * the caller: store/transmit it, don't inspect or mutate it. */
56
110
  history: Message[];
57
111
  /** Exactly what this turn added — safe to durably append regardless of
58
- * whether ContextClip recovery reshaped `history` mid-turn. A caller
112
+ * whether compaction.ts's own recovery reshaped `history` mid-turn. A caller
59
113
  * that persists conversations (see session-store.ts) should use this
60
114
  * instead of diffing `history` by length against what it loaded: that
61
115
  * only works if `history` only ever grows, which stops being true the
62
116
  * moment recovery can shrink/rewrite it, not just extend it. */
63
117
  newMessages: Message[];
64
- /** Set to 'max_turns' if the loop stopped because it hit
65
- * config.maxTurns, not because the model produced a final answer with
66
- * no more tool_use blocks — `text` in that case is a synthetic notice,
67
- * not something the model said. Absent on a normal finish. */
68
- stopReason?: 'max_turns';
118
+ /** Set when the loop stopped for a reason other than the model
119
+ * producing a final answer with no more tool_use blocks — `text` in
120
+ * either case is a synthetic notice, not something the model said.
121
+ * Absent on a normal finish.
122
+ *
123
+ * 'max_turns': hit config.maxTurns without a final answer.
124
+ *
125
+ * 'denied': a human denied at least one requested tool call this turn
126
+ * (see actauth's own 'ask'/'deny' decisions) — the loop stops right
127
+ * there instead of feeding "denied: ..." back to the model and letting
128
+ * it keep going on its own, the same way Claude Code itself stops an
129
+ * entire pending batch rather than quietly carrying out the parts you
130
+ * didn't object to. A denial cancels the *whole* turn's batch, not
131
+ * just the call(s) that were themselves denied — any other call the
132
+ * model requested in the same turn, even one already approved, never
133
+ * runs either (see the loop's own body); it gets a "skipped", not a
134
+ * "denied", tool_result, since it was never evaluated against its own
135
+ * rule. */
136
+ stopReason?: 'max_turns' | 'denied';
69
137
  }
70
138
  /** Resolves AgentConfig.rules the same way skillsDirs resolves its own
71
139
  * default: an inline array is used as-is (full 3-segment scopePattern,