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.
- package/README.md +193 -77
- package/dist/agent-config.d.ts +10 -10
- package/dist/anthropic-model-call.js +30 -18
- package/dist/index.d.ts +4 -6
- package/dist/index.js +1 -2
- package/dist/openai-model-call.d.ts +14 -0
- package/dist/openai-model-call.js +101 -0
- package/dist/run-agent.d.ts +30 -1
- package/dist/run-agent.js +147 -43
- package/dist/session-store.d.ts +41 -8
- package/dist/session-store.js +113 -36
- package/package.json +6 -8
- package/dist/load-agent.d.ts +0 -8
- package/dist/load-agent.js +0 -27
- package/dist/mcp-tools.d.ts +0 -11
- package/dist/mcp-tools.js +0 -31
package/dist/run-agent.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/session-store.d.ts
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
|
+
import { Redis } from 'ioredis';
|
|
1
2
|
import { type Storage } from 'sessionknit';
|
|
2
|
-
import type { Message } from '
|
|
3
|
+
import type { Message } from './run-agent.js';
|
|
3
4
|
export interface SessionResult<T> {
|
|
4
|
-
|
|
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
|
|
11
|
-
*
|
|
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
|
|
38
|
-
*
|
|
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
|
-
|
|
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:
|
package/dist/session-store.js
CHANGED
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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
|
|
41
|
-
// message before any tool executes, then one
|
|
42
|
-
//
|
|
43
|
-
// never see this — it lives entirely
|
|
44
|
-
// returns). If the process dies in
|
|
45
|
-
//
|
|
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' &&
|
|
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 {
|
|
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
|
|
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
|
|
124
|
-
*
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|
package/dist/load-agent.d.ts
DELETED
|
@@ -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>;
|