loopengine 0.0.1 → 0.0.2

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.
@@ -1,16 +1,38 @@
1
- import { type Message } from 'contextclip';
2
1
  import type { AgentConfig, ToolSchema } from './agent-config.js';
3
2
  export interface ModelContentBlock {
4
3
  type: string;
4
+ /** text blocks. */
5
5
  text?: string;
6
+ /** tool_use blocks: this call's own id (referenced by a later tool_result). */
6
7
  id?: string;
8
+ /** tool_use blocks. */
7
9
  name?: string;
10
+ /** tool_use blocks. */
8
11
  input?: Record<string, unknown>;
12
+ /** tool_result blocks: id of the tool_use block this result answers. */
13
+ tool_use_id?: string;
14
+ /** tool_result blocks: the tool's output (or denial/error message). */
15
+ content?: string;
16
+ /** tool_result blocks: true if the tool call failed or was denied. */
17
+ is_error?: boolean;
9
18
  }
10
19
  export interface ModelResponse {
11
20
  stop_reason: string;
12
21
  content: ModelContentBlock[];
13
22
  }
23
+ /** loopengine's own conversation-message type — a superset of
24
+ * ContextClip's `{role, content: string}`: `content` can also be a
25
+ * `ModelContentBlock[]`, the same block shape `ModelResponse.content`
26
+ * already used, so a model's tool_use requests and this loop's
27
+ * tool_result replies round-trip with real per-call identity
28
+ * (`tool_use_id`) instead of being flattened into prose. Plain-string
29
+ * content (a user's message, a skill's injected body, a synthetic
30
+ * compaction note) is just as valid — nothing requires every message to
31
+ * be block-structured. */
32
+ export interface Message {
33
+ role: string;
34
+ content: string | ModelContentBlock[];
35
+ }
14
36
  /** Swap the Anthropic SDK in here — this is the only seam a real API call needs. */
15
37
  export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[]) => Promise<ModelResponse>;
16
38
  export interface RunAgentOptions {
@@ -24,5 +46,12 @@ export interface RunAgentResult {
24
46
  * `history` on the next call to continue the conversation. Opaque to
25
47
  * the caller: store/transmit it, don't inspect or mutate it. */
26
48
  history: Message[];
49
+ /** Exactly what this turn added — safe to durably append regardless of
50
+ * whether ContextClip recovery reshaped `history` mid-turn. A caller
51
+ * that persists conversations (see session-store.ts) should use this
52
+ * instead of diffing `history` by length against what it loaded: that
53
+ * only works if `history` only ever grows, which stops being true the
54
+ * moment recovery can shrink/rewrite it, not just extend it. */
55
+ newMessages: Message[];
27
56
  }
28
57
  export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
package/dist/run-agent.js CHANGED
@@ -8,6 +8,27 @@ import { SkillGarden } from 'skillgarden';
8
8
  import { ContextClipper } from 'contextclip';
9
9
  import { ToolLane } from 'toollane';
10
10
  import { Reflow } from 'reflowkit';
11
+ /** ContextClip only ever needs a flat string per message to estimate
12
+ * token usage — it doesn't need to understand tool_use/tool_result
13
+ * structure, so this is a one-way, read-only projection, never something
14
+ * that needs to be un-projected back. */
15
+ function flattenForContextClip(message) {
16
+ if (typeof message.content === 'string')
17
+ return { role: message.role, content: message.content };
18
+ const text = message.content
19
+ .map((block) => {
20
+ if (block.type === 'text')
21
+ return block.text ?? '';
22
+ if (block.type === 'tool_use')
23
+ return `[tool_use ${block.name}(${JSON.stringify(block.input)})]`;
24
+ if (block.type === 'tool_result') {
25
+ return `[tool_result for ${block.tool_use_id}]${block.is_error ? ' ERROR' : ''}: ${block.content ?? ''}`;
26
+ }
27
+ return `[${block.type}]`;
28
+ })
29
+ .join('\n');
30
+ return { role: message.role, content: text };
31
+ }
11
32
  export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
12
33
  const log = options.onEvent ?? (() => { });
13
34
  const scope = { tenant: 'default', environment: 'production', ...config.scope, agent: config.name };
@@ -15,20 +36,64 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
15
36
  ? new SkillGarden({ dirs: config.skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
16
37
  : null;
17
38
  const skillIndex = skillGarden?.buildIndex().included ?? [];
18
- const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages: 4 });
39
+ // Also the tail-preservation window recover() below relies on — kept as
40
+ // one named constant so the two can never drift out of sync.
41
+ const tailMessages = 4;
42
+ const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
19
43
  const rules = new RuleSet(config.rules, config.defaultDecision ?? 'ask');
20
44
  const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
21
45
  const toolLane = new ToolLane({ isSafe: config.isSafeTool ?? (() => false) });
46
+ let messages = [...history, { role: 'user', content: userMessage }];
47
+ // Tracked in parallel with `messages`, but never rewritten wholesale by
48
+ // recovery the way `messages` is — this is the actual, authoritative
49
+ // answer to "what did this turn add," independent of how many times (if
50
+ // any) the head of `messages` got drained/summarized along the way. See
51
+ // pushMessage() below and the reconciliation inside onPromptTooLong.
52
+ let newMessages = [{ role: 'user', content: userMessage }];
53
+ function pushMessage(message) {
54
+ messages.push(message);
55
+ newMessages.push(message);
56
+ }
22
57
  const reflow = new Reflow({
23
- onPromptTooLong: async (messages) => {
24
- const result = await contextClip.recover(messages);
25
- log('reflow:recover', { from: messages.length, to: result.messages.length });
26
- return result.messages;
58
+ onPromptTooLong: async (currentMessages) => {
59
+ // newMessages (this turn's own content — not durably stored
60
+ // anywhere else yet) must never be handed to ContextClip at all.
61
+ // An earlier version of this reconciliation tried to recover
62
+ // newMessages *after* compaction by reusing whatever ContextClip's
63
+ // synthetic head contained — that's unsound: ContextClip's cheap
64
+ // "drain" stage doesn't produce a head at all, it just deletes old
65
+ // messages outright, trusting there's a durable copy elsewhere.
66
+ // That trust is only valid for the *prior* portion; only that
67
+ // portion is safe to compact at all.
68
+ const priorCount = currentMessages.length - newMessages.length;
69
+ const priorPortion = currentMessages.slice(0, priorCount);
70
+ const result = await contextClip.recover(priorPortion.map(flattenForContextClip));
71
+ log('reflow:recover', { from: currentMessages.length, to: result.messages.length + newMessages.length });
72
+ if (result.action === 'unchanged')
73
+ return currentMessages;
74
+ // Same tail-preservation reuse as before, just scoped to
75
+ // priorPortion alone now — newMessages is appended back whole,
76
+ // always, regardless of how large it's grown this turn.
77
+ const preservedTailCount = Math.min(tailMessages, priorPortion.length);
78
+ const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
79
+ const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
80
+ const recovered = [...newHead, ...structuredTail, ...newMessages];
81
+ // Reflow.call() only ever returns {value, recoveries, truncated} —
82
+ // it never hands back whatever `currentMessages` became after a
83
+ // retry, so recovery would otherwise only ever affect the one
84
+ // retried call. Reassigning the outer `messages` binding here (this
85
+ // hook is the only place that has both the recovered array and a
86
+ // reason to persist it) is what makes recovery durable: every
87
+ // subsequent contextClip.check() in this loop, and the `history`
88
+ // this function eventually returns, both see the compacted
89
+ // conversation from this point on — not the original, ever-growing
90
+ // one. newMessages itself needs no reconciliation at all — it was
91
+ // never part of what could be compacted away.
92
+ messages = recovered;
93
+ return recovered;
27
94
  },
28
95
  });
29
- // Optional on AgentConfig — an MCP-only agent's tools are populated by
30
- // load-agent.ts before this ever runs, but default defensively here too
31
- // in case a config reaches runAgent without going through it.
96
+ // Optional on AgentConfig — an agent can have zero tools.
32
97
  const tools = config.tools ?? [];
33
98
  const toolsByName = new Map(tools.map((t) => [t.name, t]));
34
99
  const toolSchemas = tools.map(({ name, description, input_schema }) => ({
@@ -36,65 +101,104 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
36
101
  description,
37
102
  input_schema,
38
103
  }));
104
+ // The system prompt below only ever listed skill names/descriptions as
105
+ // prose — nothing told a real model it could actually call a tool named
106
+ // "Skill" to invoke one, so it never would have. One shared schema, not
107
+ // one per skill: the skill's own name is an *input*, exactly what keeps
108
+ // the tool list cheap regardless of how many skills exist — the whole
109
+ // point of SkillGarden's index-now-load-later design. See the
110
+ // toolUseBlocks loop below for where this is actually answered.
111
+ if (skillIndex.length) {
112
+ toolSchemas.push({
113
+ name: 'Skill',
114
+ description: 'Invoke one of the available skills listed in the system prompt, by name.',
115
+ input_schema: {
116
+ type: 'object',
117
+ properties: {
118
+ skill: { type: 'string', description: 'Name of the skill to invoke, exactly as listed.' },
119
+ args: {
120
+ type: 'string',
121
+ description: 'Optional arguments for the skill body — substituted for $ARGUMENTS/$1/$2 placeholders.',
122
+ },
123
+ },
124
+ required: ['skill'],
125
+ },
126
+ });
127
+ }
39
128
  const systemPrompt = skillIndex.length
40
129
  ? `${config.systemPrompt}\n\nAvailable skills:\n${skillIndex.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
41
130
  : config.systemPrompt;
42
- let messages = [...history, { role: 'user', content: userMessage }];
43
131
  for (;;) {
44
- const budget = contextClip.check(messages);
132
+ const budget = contextClip.check(messages.map(flattenForContextClip));
45
133
  log('contextclip:check', budget);
46
134
  if (budget.action === 'nudge' && budget.nudge)
47
- messages.push(budget.nudge);
135
+ pushMessage(budget.nudge);
48
136
  const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
49
137
  if (recoveries.length > 0)
50
138
  log('reflow:recoveries', recoveries);
139
+ // The model's full response — text and tool_use blocks alike, with
140
+ // real ids — becomes this turn's assistant message verbatim. Every
141
+ // tool_use block emitted here gets a matching tool_result pushed
142
+ // below before the loop continues; a real model API expects exactly
143
+ // that pairing on the next request, not a prose summary of it.
144
+ pushMessage({ role: 'assistant', content: response.content });
51
145
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
52
146
  if (toolUseBlocks.length === 0) {
53
147
  const text = response.content.find((b) => b.type === 'text')?.text ?? '';
54
148
  log('loop:done', text);
55
- messages.push({ role: 'assistant', content: text });
56
- return { text, history: messages };
57
- }
58
- // Skill invocation injects instructions into context; it isn't a tool
59
- // that needs ActAuth gating or ToolLane scheduling.
60
- const skillCall = toolUseBlocks.find((b) => b.name === 'Skill');
61
- if (skillCall && skillGarden) {
62
- const body = skillGarden.invoke(skillCall.input.skill);
63
- log('skillgarden:invoke', skillCall.input.skill);
64
- messages.push({ role: 'assistant', content: `[called Skill: ${skillCall.input.skill}]` });
65
- messages.push({ role: 'user', content: body });
66
- continue;
149
+ return { text, history: messages, newMessages };
67
150
  }
68
- const approved = [];
69
- const denied = [];
70
- const reasons = new Map();
151
+ const resultBlocks = [];
152
+ const approvedCalls = [];
71
153
  for (const block of toolUseBlocks) {
154
+ // Skill invocation injects instructions into context; it isn't a
155
+ // real tool call and doesn't need ActAuth gating or ToolLane
156
+ // scheduling — but it's still a tool_use block the model emitted,
157
+ // so it still needs a tool_result to answer it, same as any other.
158
+ if (block.name === 'Skill' && skillGarden) {
159
+ const body = skillGarden.invoke(block.input.skill, block.input?.args);
160
+ log('skillgarden:invoke', block.input.skill);
161
+ resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
162
+ continue;
163
+ }
72
164
  const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
73
165
  log('actauth:decision', { tool: block.name, decision: decision.decision, reason: decision.reason });
74
166
  if (decision.decision === 'allow') {
75
- approved.push(block);
167
+ approvedCalls.push({
168
+ id: block.id,
169
+ name: block.name,
170
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
171
+ });
76
172
  }
77
173
  else {
78
- denied.push(block);
79
- reasons.set(block.id, decision.reason);
174
+ // Every requested tool gets exactly one tool_result back — a
175
+ // denied call is not simply dropped, or the model has no way to
176
+ // tell "denied" apart from "hasn't run yet" and may just
177
+ // re-request it forever.
178
+ resultBlocks.push({
179
+ type: 'tool_result',
180
+ tool_use_id: block.id,
181
+ content: `denied: ${decision.reason}`,
182
+ is_error: true,
183
+ });
80
184
  }
81
185
  }
82
- messages.push({ role: 'assistant', content: `[requested: ${toolUseBlocks.map((b) => b.name).join(', ')}]` });
83
- // Every requested tool gets exactly one result message back — a denied
84
- // call is not simply dropped, or the model has no way to tell "denied"
85
- // apart from "hasn't run yet" and may just re-request it forever.
86
- for (const block of denied) {
87
- messages.push({ role: 'user', content: `[${block.name} result] denied: ${reasons.get(block.id)}` });
88
- }
89
- const calls = approved.map((b) => ({
90
- id: b.id,
91
- name: b.name,
92
- execute: () => toolsByName.get(b.name).execute(b.input ?? {}),
93
- }));
94
- for await (const result of toolLane.run(calls)) {
186
+ // result.id is the same id LaneCall.id was given above — the exact
187
+ // per-call identity that makes it possible to link a result back to
188
+ // the specific tool_use block that requested it, even when several
189
+ // calls ran in the same parallel lane. The old flattened-text design
190
+ // could only link by tool *name*, ambiguous the moment two calls to
191
+ // the same tool ran in one turn.
192
+ for await (const result of toolLane.run(approvedCalls)) {
95
193
  const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
96
194
  log('toollane:result', { name: result.name, summary });
97
- messages.push({ role: 'user', content: `[${result.name} result] ${summary}` });
195
+ resultBlocks.push({
196
+ type: 'tool_result',
197
+ tool_use_id: result.id,
198
+ content: summary,
199
+ is_error: result.status === 'rejected',
200
+ });
98
201
  }
202
+ pushMessage({ role: 'user', content: resultBlocks });
99
203
  }
100
204
  }
@@ -1,17 +1,36 @@
1
+ import { Redis } from 'ioredis';
1
2
  import { type Storage } from 'sessionknit';
2
- import type { Message } from 'contextclip';
3
+ import type { Message } from './run-agent.js';
3
4
  export interface SessionResult<T> {
4
- history: Message[];
5
+ /** Exactly what this turn added — durably appended as-is, in order.
6
+ * This has to come from the caller explicitly (runAgent's own
7
+ * RunAgentResult.newMessages) rather than being inferred by diffing a
8
+ * returned `history` against what withSession loaded: that only works
9
+ * if history only ever grows, which isn't true once ContextClip
10
+ * recovery can shrink/rewrite it mid-turn — see RunAgentResult's own
11
+ * doc comment for the full reasoning. */
12
+ newMessages: Message[];
5
13
  result: T;
6
14
  }
7
15
  export interface SessionStore {
8
16
  /** Loads history for sessionId, runs fn exclusively — no other
9
17
  * withSession call for the same sessionId runs concurrently, in this
10
- * process or (for RedisSessionStore) any other — appends the new
11
- * messages fn's result adds, and resolves with fn's result. */
18
+ * process or (for RedisSessionStore) any other — durably appends
19
+ * fn's result.newMessages in order, and resolves with fn's result. */
12
20
  withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
13
21
  close(): Promise<void>;
14
22
  }
23
+ /** Serializes async work per key within this process. Good enough for
24
+ * FileSessionStore (single process); RedisSessionStore needs a real
25
+ * distributed lock instead since multiple server instances share it. */
26
+ export declare class KeyedMutex {
27
+ private tails;
28
+ run<T>(key: string, fn: () => Promise<T>): Promise<T>;
29
+ /** Test-only: how many keys currently have an in-flight or
30
+ * about-to-be-cleaned-up chain — should return to 0 once all queued
31
+ * work across all keys has settled. */
32
+ size(): number;
33
+ }
15
34
  /** Wraps a SessionKnit log behind the flat load/run/append contract
16
35
  * runAgent's callers use. Shared by both the file- and Redis-backed
17
36
  * stores below — only the Storage and the lock differ. */
@@ -29,20 +48,34 @@ declare class SessionKnitStore implements SessionStore {
29
48
  export declare class FileSessionStore extends SessionKnitStore {
30
49
  constructor(dir: string);
31
50
  }
51
+ export interface RedisSessionStoreOptions {
52
+ lockTtlMs?: number;
53
+ lockRetryDelayMs?: number;
54
+ lockMaxWaitMs?: number;
55
+ /** Inject a pre-built client instead of connecting from redisUrl — e.g. to pass a fake in tests. */
56
+ client?: Redis;
57
+ }
32
58
  /** Redis-backed store for multi-instance deployments. Locking is a
33
59
  * single-instance-Redis lock (SET NX PX + a compare-and-delete unlock
34
60
  * script) — good enough as long as session state lives in one Redis; true
35
61
  * multi-node Redlock is overkill until it doesn't. This lock is what keeps
36
62
  * two concurrent turns for the same session from interleaving appends
37
- * across instances — SessionKnit's own topology repair handles branching
38
- * *within* one resumed chain, not that. */
63
+ * across instances — SessionKnit's own topology repair (reattaching
64
+ * sibling branches within one resumed chain) is a defensive read-side
65
+ * repair for crash recovery, not a substitute for that. */
39
66
  export declare class RedisSessionStore extends SessionKnitStore {
67
+ private readonly redis;
40
68
  private readonly lockTtlMs;
41
69
  private readonly lockRetryDelayMs;
42
70
  private readonly lockMaxWaitMs;
43
- private readonly redis;
44
- constructor(redisUrl: string, lockTtlMs?: number, lockRetryDelayMs?: number, lockMaxWaitMs?: number);
71
+ constructor(redisUrl: string, options?: RedisSessionStoreOptions);
45
72
  private lockKeyFor;
73
+ /** True if we still (or again) hold the lock after this call — false
74
+ * means someone else's SET NX PX has already replaced ours. A thrown
75
+ * error here doesn't mean that on its own; it's treated as "try again
76
+ * next tick" by the caller, since a transient network blip renewing the
77
+ * TTL isn't proof the lock is actually gone. */
78
+ private renewLock;
46
79
  private withLock;
47
80
  }
48
81
  /** Picks the store from environment so adapters don't hardcode a backend:
@@ -10,17 +10,22 @@
10
10
  // - non-blocking, debounced writes instead of a full read-modify-write
11
11
  // of the entire session on every turn
12
12
  // - interruption detection — if the process dies between requesting
13
- // tools and recording their results (run-agent.ts pushes an
14
- // "[requested: ...]" message before any tool runs, then one
15
- // "[... result]" message per completed tool), the next resume() sees
16
- // an unresolved request and injects a note into context saying so,
17
- // instead of silently resending an incomplete turn as if it were
13
+ // tools and recording their results (run-agent.ts pushes the
14
+ // assistant's tool_use blocks as one message before any tool runs,
15
+ // then one message bundling every tool_result once they've all
16
+ // settled), the next resume() sees an assistant message with an
17
+ // unanswered tool_use block and injects a note into context saying
18
+ // so, instead of silently resending an incomplete turn as if it were
18
19
  // clean.
19
- // SessionKnit's own topology repair is for branches *within* one resumed
20
- // chain (parallel tool calls, crash recovery) — it isn't a substitute for
21
- // turn-level exclusivity, so this module still serializes concurrent
22
- // withSession calls for the same sessionId itself (KeyedMutex / a Redis
23
- // lock), same as before.
20
+ // SessionKnit's own topology repair (reattaching sibling branches under a
21
+ // shared parentId) is a defensive read-side repair for crash/corruption
22
+ // recovery, not something normal operation exercises — run-agent.ts
23
+ // bundles a whole turn's tool_use/tool_result blocks into one message
24
+ // each, and withSession below appends newMessages sequentially, so a
25
+ // turn's own appends are always a linear chain, siblings or not. It isn't
26
+ // a substitute for turn-level exclusivity either way, so this module
27
+ // still serializes concurrent withSession calls for the same sessionId
28
+ // itself (KeyedMutex / a Redis lock), same as before.
24
29
  import { randomUUID } from 'node:crypto';
25
30
  import path from 'node:path';
26
31
  import { Redis } from 'ioredis';
@@ -28,23 +33,47 @@ import { SessionKnit, FileStorage } from 'sessionknit';
28
33
  /** Serializes async work per key within this process. Good enough for
29
34
  * FileSessionStore (single process); RedisSessionStore needs a real
30
35
  * distributed lock instead since multiple server instances share it. */
31
- class KeyedMutex {
36
+ export class KeyedMutex {
32
37
  tails = new Map();
33
38
  run(key, fn) {
34
39
  const prevTail = this.tails.get(key) ?? Promise.resolve();
35
40
  const result = prevTail.then(fn, fn);
36
- this.tails.set(key, result.catch(() => { }));
41
+ const settled = result.catch(() => { });
42
+ this.tails.set(key, settled);
43
+ // Self-cleaning: once nothing is queued behind this key anymore,
44
+ // drop it instead of leaking one Map entry per distinct key forever.
45
+ // Every anonymous, auto-generated session id (see adapters/http.ts,
46
+ // adapters/cli.ts) creates a brand-new key that would otherwise
47
+ // never be visited again — an unbounded, permanent leak in a
48
+ // long-running process. Identity-checked against the Map's *current*
49
+ // value for this key, not just "did settled resolve" — a call queued
50
+ // behind this one already replaced the entry with its own tail by
51
+ // the time this fires, and deleting then would orphan that chain
52
+ // instead of leaving it for its own turn to clean up.
53
+ settled.then(() => {
54
+ if (this.tails.get(key) === settled)
55
+ this.tails.delete(key);
56
+ });
37
57
  return result;
38
58
  }
59
+ /** Test-only: how many keys currently have an in-flight or
60
+ * about-to-be-cleaned-up chain — should return to 0 once all queued
61
+ * work across all keys has settled. */
62
+ size() {
63
+ return this.tails.size;
64
+ }
39
65
  }
40
- // run-agent.ts pushes exactly one "[requested: tool_a, tool_b]" assistant
41
- // message before any tool executes, then one "[tool result]" user message
42
- // per completed tool as they finish (adapters/http.ts, adapters/cli.ts
43
- // never see this — it lives entirely inside the messages array runAgent
44
- // returns). If the process dies in that window, the request message is the
45
- // last thing on disk with no result behind it.
66
+ // run-agent.ts pushes the model's full response — tool_use blocks
67
+ // included — as one assistant message before any tool executes, then one
68
+ // user message bundling every tool_result once they've all settled
69
+ // (adapters/http.ts, adapters/cli.ts never see this — it lives entirely
70
+ // inside the messages array runAgent returns). If the process dies in
71
+ // that window, the assistant message with its unanswered tool_use
72
+ // block(s) is the last thing on disk.
46
73
  function hasUnresolvedToolCall(message) {
47
- return message.role === 'assistant' && message.content.startsWith('[requested:');
74
+ return (message.role === 'assistant' &&
75
+ Array.isArray(message.content) &&
76
+ message.content.some((block) => block.type === 'tool_use'));
48
77
  }
49
78
  function buildContinuation() {
50
79
  return {
@@ -67,13 +96,9 @@ class SessionKnitStore {
67
96
  async withSession(sessionId, fn) {
68
97
  return this.lock(sessionId, async () => {
69
98
  const { messages, leafId } = await this.knit.resume(sessionId);
70
- const { history: nextHistory, result } = await fn(messages);
71
- // Everything fn's history grew by beyond what resume() handed it —
72
- // this naturally excludes the synthetic continuation message too
73
- // (it's already counted in messages.length), which is exactly right:
74
- // it's a resend-only hint, never meant to be durably stored.
99
+ const { newMessages, result } = await fn(messages);
75
100
  let parentId = leafId;
76
- for (const message of nextHistory.slice(messages.length)) {
101
+ for (const message of newMessages) {
77
102
  const id = randomUUID();
78
103
  await this.knit.append(sessionId, { id, parentId, message });
79
104
  parentId = id;
@@ -115,29 +140,41 @@ class RedisEntryStorage {
115
140
  return raw.map((line) => JSON.parse(line));
116
141
  }
117
142
  }
143
+ const UNLOCK_IF_OWNER_SCRIPT = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
144
+ const RENEW_IF_OWNER_SCRIPT = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;
118
145
  /** Redis-backed store for multi-instance deployments. Locking is a
119
146
  * single-instance-Redis lock (SET NX PX + a compare-and-delete unlock
120
147
  * script) — good enough as long as session state lives in one Redis; true
121
148
  * multi-node Redlock is overkill until it doesn't. This lock is what keeps
122
149
  * two concurrent turns for the same session from interleaving appends
123
- * across instances — SessionKnit's own topology repair handles branching
124
- * *within* one resumed chain, not that. */
150
+ * across instances — SessionKnit's own topology repair (reattaching
151
+ * sibling branches within one resumed chain) is a defensive read-side
152
+ * repair for crash recovery, not a substitute for that. */
125
153
  export class RedisSessionStore extends SessionKnitStore {
154
+ redis;
126
155
  lockTtlMs;
127
156
  lockRetryDelayMs;
128
157
  lockMaxWaitMs;
129
- redis;
130
- constructor(redisUrl, lockTtlMs = 30_000, lockRetryDelayMs = 50, lockMaxWaitMs = 15_000) {
131
- const redis = new Redis(redisUrl);
158
+ constructor(redisUrl, options = {}) {
159
+ const redis = options.client ?? new Redis(redisUrl);
132
160
  super(new RedisEntryStorage(redis), (sessionId, fn) => this.withLock(sessionId, fn), () => redis.quit().then(() => { }));
133
- this.lockTtlMs = lockTtlMs;
134
- this.lockRetryDelayMs = lockRetryDelayMs;
135
- this.lockMaxWaitMs = lockMaxWaitMs;
136
161
  this.redis = redis;
162
+ this.lockTtlMs = options.lockTtlMs ?? 30_000;
163
+ this.lockRetryDelayMs = options.lockRetryDelayMs ?? 50;
164
+ this.lockMaxWaitMs = options.lockMaxWaitMs ?? 15_000;
137
165
  }
138
166
  lockKeyFor(sessionId) {
139
167
  return `session-lock:${sessionId}`;
140
168
  }
169
+ /** True if we still (or again) hold the lock after this call — false
170
+ * means someone else's SET NX PX has already replaced ours. A thrown
171
+ * error here doesn't mean that on its own; it's treated as "try again
172
+ * next tick" by the caller, since a transient network blip renewing the
173
+ * TTL isn't proof the lock is actually gone. */
174
+ async renewLock(lockKey, token) {
175
+ const result = await this.redis.eval(RENEW_IF_OWNER_SCRIPT, 1, lockKey, token, this.lockTtlMs);
176
+ return result === 1;
177
+ }
141
178
  async withLock(sessionId, fn) {
142
179
  const lockKey = this.lockKeyFor(sessionId);
143
180
  const token = randomUUID();
@@ -150,14 +187,54 @@ export class RedisSessionStore extends SessionKnitStore {
150
187
  throw new Error(`timed out waiting for lock on session '${sessionId}'`);
151
188
  await new Promise((resolve) => setTimeout(resolve, this.lockRetryDelayMs));
152
189
  }
190
+ // Without renewal, a turn that runs longer than lockTtlMs (a real
191
+ // model call plus several tool round-trips easily can) would
192
+ // silently lose the lock mid-flight — a second concurrent request
193
+ // for the same session could then acquire it and run concurrently,
194
+ // interleaving appends into the same durable log. Renewing at a
195
+ // fraction of the TTL (not waiting until right before expiry) leaves
196
+ // real margin for a slow renewal call itself to still land in time.
197
+ // Recursive setTimeout, not setInterval, so a slow renewal can never
198
+ // overlap with the next one — each renewal only schedules the next
199
+ // after the previous has settled.
200
+ let lockLost = false;
201
+ let renewTimer;
202
+ const scheduleRenewal = () => {
203
+ renewTimer = setTimeout(async () => {
204
+ try {
205
+ if (!(await this.renewLock(lockKey, token))) {
206
+ lockLost = true;
207
+ return; // confirmed gone — no point scheduling further renewals
208
+ }
209
+ }
210
+ catch {
211
+ // Renewal call itself failed (network blip, etc.) — not proof
212
+ // the lock is lost, only a confirmed token mismatch is. Try
213
+ // again next tick; the TTL still bounds how long we can be
214
+ // wrong about still holding it.
215
+ }
216
+ scheduleRenewal();
217
+ }, Math.floor(this.lockTtlMs / 3));
218
+ };
219
+ scheduleRenewal();
153
220
  try {
154
- return await fn();
221
+ const result = await fn();
222
+ if (lockLost) {
223
+ // fn() already ran to completion — there's no way to undo or
224
+ // cancel work already in flight, only to make the failure loud
225
+ // instead of silently returning as if mutual exclusion held the
226
+ // whole time. Whatever this turn wrote may be interleaved with a
227
+ // concurrent request's writes.
228
+ throw new Error(`lock for session '${sessionId}' was lost mid-turn — a concurrent request may have run ` +
229
+ 'against the same session; this turn\'s result should not be trusted');
230
+ }
231
+ return result;
155
232
  }
156
233
  finally {
234
+ clearTimeout(renewTimer);
157
235
  // Only delete if we still hold it — otherwise we'd release a lock our
158
236
  // TTL already expired and a different request has since acquired.
159
- const script = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
160
- await this.redis.eval(script, 1, lockKey, token);
237
+ await this.redis.eval(UNLOCK_IF_OWNER_SCRIPT, 1, lockKey, token);
161
238
  }
162
239
  }
163
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "description": "A minimal framework for defining and running tool-using AI agents over CLI, HTTP, or both.",
6
6
  "license": "MIT",
@@ -17,16 +17,14 @@
17
17
  "dist/run-agent.d.ts",
18
18
  "dist/agent-config.js",
19
19
  "dist/agent-config.d.ts",
20
- "dist/load-agent.js",
21
- "dist/load-agent.d.ts",
22
- "dist/mcp-tools.js",
23
- "dist/mcp-tools.d.ts",
24
20
  "dist/session-store.js",
25
21
  "dist/session-store.d.ts",
26
22
  "dist/vector-index.js",
27
23
  "dist/vector-index.d.ts",
28
24
  "dist/anthropic-model-call.js",
29
25
  "dist/anthropic-model-call.d.ts",
26
+ "dist/openai-model-call.js",
27
+ "dist/openai-model-call.d.ts",
30
28
  "LICENSE"
31
29
  ],
32
30
  "scripts": {
@@ -37,17 +35,17 @@
37
35
  },
38
36
  "dependencies": {
39
37
  "@anthropic-ai/sdk": "^0.116.0",
40
- "@modelcontextprotocol/sdk": "^1.30.0",
41
38
  "actauth": "^0.0.2",
42
39
  "contextclip": "^0.0.1",
43
40
  "ioredis": "^6.0.0",
41
+ "mcpplug": "^0.0.1",
42
+ "openai": "^7.4.0",
44
43
  "reflowkit": "^0.0.1",
45
44
  "sessionknit": "^0.0.2",
46
- "skillgarden": "^0.0.1",
45
+ "skillgarden": "^0.0.4",
47
46
  "toollane": "^0.0.1"
48
47
  },
49
48
  "devDependencies": {
50
- "@modelcontextprotocol/server-filesystem": "^2026.7.10",
51
49
  "@types/node": "^26.2.0",
52
50
  "tsx": "^4.19.0",
53
51
  "typescript": "^5.7.0",
@@ -1,8 +0,0 @@
1
- import type { AgentConfig } from './agent-config.js';
2
- export interface LoadedAgent {
3
- /** Same config, with tools = hand-written tools + everything discovered from mcpServers. */
4
- config: AgentConfig;
5
- /** Closes every MCP connection this load opened. No-op if the config had no mcpServers. */
6
- close: () => Promise<void>;
7
- }
8
- export declare function loadAgent(config: AgentConfig): Promise<LoadedAgent>;