@possumtech/rummy 0.2.7 → 0.3.0
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/.env.example +12 -3
- package/EXCEPTIONS.md +46 -0
- package/PLUGINS.md +454 -197
- package/SPEC.md +284 -93
- package/migrations/001_initial_schema.sql +57 -70
- package/package.json +16 -10
- package/service.js +1 -1
- package/src/agent/AgentLoop.js +254 -70
- package/src/agent/ContextAssembler.js +18 -4
- package/src/agent/KnownStore.js +156 -23
- package/src/agent/ProjectAgent.js +5 -4
- package/src/agent/ResponseHealer.js +21 -1
- package/src/agent/TurnExecutor.js +393 -115
- package/src/agent/XmlParser.js +92 -39
- package/src/agent/known_checks.sql +5 -4
- package/src/agent/known_queries.sql +4 -3
- package/src/agent/known_store.sql +45 -15
- package/src/agent/loops.sql +63 -0
- package/src/agent/runs.sql +7 -7
- package/src/agent/schemes.sql +5 -2
- package/src/agent/tokens.js +6 -21
- package/src/agent/turns.sql +13 -4
- package/src/hooks/Hooks.js +18 -0
- package/src/hooks/PluginContext.js +14 -10
- package/src/hooks/RummyContext.js +30 -10
- package/src/hooks/ToolRegistry.js +83 -19
- package/src/llm/LlmProvider.js +27 -8
- package/src/llm/OpenAiClient.js +20 -0
- package/src/llm/OpenRouterClient.js +24 -2
- package/src/llm/XaiClient.js +47 -2
- package/src/plugins/ask_user/README.md +4 -4
- package/src/plugins/ask_user/ask_user.js +8 -7
- package/src/plugins/ask_user/ask_userDoc.js +29 -0
- package/src/plugins/budget/BudgetGuard.js +74 -0
- package/src/plugins/budget/README.md +43 -0
- package/src/plugins/budget/budget.js +79 -0
- package/src/plugins/cp/README.md +5 -4
- package/src/plugins/cp/cp.js +16 -12
- package/src/plugins/cp/cpDoc.js +29 -0
- package/src/plugins/current/README.md +4 -4
- package/src/plugins/current/current.js +12 -10
- package/src/plugins/engine/engine.sql +5 -10
- package/src/plugins/engine/turn_context.sql +13 -13
- package/src/plugins/env/README.md +3 -4
- package/src/plugins/env/env.js +8 -7
- package/src/plugins/env/envDoc.js +29 -0
- package/src/plugins/file/README.md +9 -12
- package/src/plugins/file/file.js +34 -45
- package/src/plugins/get/README.md +2 -2
- package/src/plugins/get/get.js +28 -11
- package/src/plugins/get/getDoc.js +41 -0
- package/src/plugins/hedberg/docs.md +0 -9
- package/src/plugins/hedberg/hedberg.js +4 -6
- package/src/plugins/hedberg/matcher.js +1 -1
- package/src/plugins/hedberg/normalize.js +28 -0
- package/src/plugins/hedberg/patterns.js +31 -33
- package/src/plugins/hedberg/sed.js +17 -10
- package/src/plugins/helpers.js +2 -2
- package/src/plugins/index.js +93 -28
- package/src/plugins/instructions/README.md +6 -2
- package/src/plugins/instructions/instructions.js +21 -5
- package/src/plugins/instructions/preamble.md +9 -5
- package/src/plugins/known/README.md +10 -7
- package/src/plugins/known/known.js +33 -23
- package/src/plugins/known/knownDoc.js +33 -0
- package/src/plugins/mv/README.md +5 -4
- package/src/plugins/mv/mv.js +16 -12
- package/src/plugins/mv/mvDoc.js +31 -0
- package/src/plugins/persona/persona.js +78 -0
- package/src/plugins/previous/README.md +2 -2
- package/src/plugins/previous/previous.js +12 -8
- package/src/plugins/progress/progress.js +44 -12
- package/src/plugins/prompt/README.md +5 -5
- package/src/plugins/prompt/prompt.js +23 -19
- package/src/plugins/rm/README.md +4 -4
- package/src/plugins/rm/rm.js +29 -12
- package/src/plugins/rm/rmDoc.js +30 -0
- package/src/plugins/rpc/README.md +15 -28
- package/src/plugins/rpc/rpc.js +63 -107
- package/src/plugins/set/README.md +13 -12
- package/src/plugins/set/set.js +82 -21
- package/src/plugins/set/setDoc.js +45 -0
- package/src/plugins/sh/README.md +4 -4
- package/src/plugins/sh/sh.js +8 -7
- package/src/plugins/sh/shDoc.js +29 -0
- package/src/plugins/{skills/skills.js → skill/skill.js} +12 -54
- package/src/plugins/summarize/README.md +6 -5
- package/src/plugins/summarize/summarize.js +7 -6
- package/src/plugins/summarize/summarizeDoc.js +33 -0
- package/src/plugins/telemetry/telemetry.js +20 -8
- package/src/plugins/think/README.md +20 -0
- package/src/plugins/think/think.js +5 -0
- package/src/plugins/unknown/README.md +5 -5
- package/src/plugins/unknown/unknown.js +11 -8
- package/src/plugins/unknown/unknownDoc.js +31 -0
- package/src/plugins/update/README.md +3 -8
- package/src/plugins/update/update.js +7 -6
- package/src/plugins/update/updateDoc.js +33 -0
- package/src/server/ClientConnection.js +3 -5
- package/src/server/RpcRegistry.js +52 -4
- package/src/sql/v_model_context.sql +31 -39
- package/src/sql/v_run_log.sql +3 -3
- package/src/agent/prompt_queue.sql +0 -39
- package/src/plugins/ask_user/docs.md +0 -2
- package/src/plugins/cp/docs.md +0 -2
- package/src/plugins/env/docs.md +0 -2
- package/src/plugins/get/docs.md +0 -6
- package/src/plugins/known/docs.md +0 -3
- package/src/plugins/mv/docs.md +0 -2
- package/src/plugins/rm/docs.md +0 -4
- package/src/plugins/set/docs.md +0 -4
- package/src/plugins/sh/docs.md +0 -2
- package/src/plugins/skills/README.md +0 -25
- package/src/plugins/store/README.md +0 -20
- package/src/plugins/store/docs.md +0 -5
- package/src/plugins/store/store.js +0 -52
- package/src/plugins/summarize/docs.md +0 -4
- package/src/plugins/unknown/docs.md +0 -5
- package/src/plugins/update/docs.md +0 -4
|
@@ -51,8 +51,24 @@ export default class RummyContext {
|
|
|
51
51
|
return this.#context.turnId || null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
get
|
|
55
|
-
return this.#context.
|
|
54
|
+
get loopId() {
|
|
55
|
+
return this.#context.loopId || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get noRepo() {
|
|
59
|
+
return this.#context.noRepo === true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get noInteraction() {
|
|
63
|
+
return this.#context.noInteraction === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get noWeb() {
|
|
67
|
+
return this.#context.noWeb === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get toolSet() {
|
|
71
|
+
return this.#context.toolSet || null;
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
get contextSize() {
|
|
@@ -85,7 +101,7 @@ export default class RummyContext {
|
|
|
85
101
|
|
|
86
102
|
// --- Tool methods (same operations the model uses) ---
|
|
87
103
|
|
|
88
|
-
async set({ path, body,
|
|
104
|
+
async set({ path, body, status = 200, fidelity, attributes } = {}) {
|
|
89
105
|
if (!path) {
|
|
90
106
|
const slugify = (await import("../sql/functions/slugify.js")).default;
|
|
91
107
|
const base = slugify(body || "");
|
|
@@ -96,8 +112,8 @@ export default class RummyContext {
|
|
|
96
112
|
this.sequence,
|
|
97
113
|
path,
|
|
98
114
|
body || "",
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
status,
|
|
116
|
+
{ fidelity, attributes, loopId: this.loopId },
|
|
101
117
|
);
|
|
102
118
|
return path;
|
|
103
119
|
}
|
|
@@ -117,14 +133,18 @@ export default class RummyContext {
|
|
|
117
133
|
async mv(from, to) {
|
|
118
134
|
const body = await this.entries.getBody(this.runId, from);
|
|
119
135
|
if (body === null) return;
|
|
120
|
-
await this.entries.upsert(this.runId, this.sequence, to, body,
|
|
136
|
+
await this.entries.upsert(this.runId, this.sequence, to, body, 200, {
|
|
137
|
+
loopId: this.loopId,
|
|
138
|
+
});
|
|
121
139
|
await this.entries.remove(this.runId, from);
|
|
122
140
|
}
|
|
123
141
|
|
|
124
142
|
async cp(from, to) {
|
|
125
143
|
const body = await this.entries.getBody(this.runId, from);
|
|
126
144
|
if (body === null) return;
|
|
127
|
-
await this.entries.upsert(this.runId, this.sequence, to, body,
|
|
145
|
+
await this.entries.upsert(this.runId, this.sequence, to, body, 200, {
|
|
146
|
+
loopId: this.loopId,
|
|
147
|
+
});
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
// --- Plugin-only methods (superset) ---
|
|
@@ -137,9 +157,9 @@ export default class RummyContext {
|
|
|
137
157
|
return this.entries.getAttributes(this.runId, path);
|
|
138
158
|
}
|
|
139
159
|
|
|
140
|
-
async
|
|
160
|
+
async getStatus(path) {
|
|
141
161
|
const row = await this.entries.getState(this.runId, path);
|
|
142
|
-
return row?.
|
|
162
|
+
return row?.status ?? null;
|
|
143
163
|
}
|
|
144
164
|
|
|
145
165
|
async getEntry(path) {
|
|
@@ -161,7 +181,7 @@ export default class RummyContext {
|
|
|
161
181
|
|
|
162
182
|
async log(message) {
|
|
163
183
|
const path = `content://${Date.now()}`;
|
|
164
|
-
await this.entries.upsert(this.runId, this.sequence, path, message,
|
|
184
|
+
await this.entries.upsert(this.runId, this.sequence, path, message, 200);
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
// --- Node tree methods ---
|
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
// Tool display order: gather → reason → act → communicate.
|
|
2
|
+
// Position in the list implies priority to the model.
|
|
3
|
+
const TOOL_ORDER = [
|
|
4
|
+
"get",
|
|
5
|
+
"set",
|
|
6
|
+
"known",
|
|
7
|
+
"unknown",
|
|
8
|
+
"env",
|
|
9
|
+
"sh",
|
|
10
|
+
"rm",
|
|
11
|
+
"cp",
|
|
12
|
+
"mv",
|
|
13
|
+
"search",
|
|
14
|
+
"summarize",
|
|
15
|
+
"update",
|
|
16
|
+
"ask_user",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function sortByPriority(names) {
|
|
20
|
+
return names.toSorted((a, b) => {
|
|
21
|
+
const ia = TOOL_ORDER.indexOf(a);
|
|
22
|
+
const ib = TOOL_ORDER.indexOf(b);
|
|
23
|
+
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
|
24
|
+
if (ia === -1) return 1;
|
|
25
|
+
if (ib === -1) return 1;
|
|
26
|
+
return ia - ib;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
1
30
|
export default class ToolRegistry {
|
|
2
31
|
#tools = new Map();
|
|
3
32
|
#handlers = new Map();
|
|
@@ -5,12 +34,9 @@ export default class ToolRegistry {
|
|
|
5
34
|
|
|
6
35
|
ensureTool(scheme) {
|
|
7
36
|
if (this.#tools.has(scheme)) return;
|
|
8
|
-
this.#tools.set(scheme, Object.freeze({
|
|
37
|
+
this.#tools.set(scheme, Object.freeze({}));
|
|
9
38
|
}
|
|
10
39
|
|
|
11
|
-
// Exception: old register() removed. Plugins use core.on("handler")/core.on("full").
|
|
12
|
-
// The only remaining caller pathway is ensureTool + onHandle + onView.
|
|
13
|
-
|
|
14
40
|
get(name) {
|
|
15
41
|
return this.#tools.get(name);
|
|
16
42
|
}
|
|
@@ -39,10 +65,33 @@ export default class ToolRegistry {
|
|
|
39
65
|
`Every tool must define how its entries appear in the model view.`,
|
|
40
66
|
);
|
|
41
67
|
}
|
|
68
|
+
|
|
69
|
+
const attrs =
|
|
70
|
+
typeof entry.attributes === "string"
|
|
71
|
+
? JSON.parse(entry.attributes)
|
|
72
|
+
: entry.attributes;
|
|
73
|
+
const summary = typeof attrs?.summary === "string" ? attrs.summary : null;
|
|
74
|
+
|
|
42
75
|
const fidelity = entry.fidelity || "full";
|
|
43
76
|
const fn = fidelityMap.get(fidelity);
|
|
44
|
-
if (!fn)
|
|
45
|
-
|
|
77
|
+
if (!fn) {
|
|
78
|
+
// No view for this fidelity — fall back on model-authored summary
|
|
79
|
+
return summary || "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const body = await fn(entry);
|
|
83
|
+
|
|
84
|
+
// Prepend summary keywords above plugin output at summary fidelity
|
|
85
|
+
if (fidelity === "summary" && summary && body) {
|
|
86
|
+
return `${summary}\n${body}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fall back to summary attribute when plugin returns empty
|
|
90
|
+
if (fidelity === "summary" && summary && !body) {
|
|
91
|
+
return summary;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return body;
|
|
46
95
|
}
|
|
47
96
|
|
|
48
97
|
hasView(scheme) {
|
|
@@ -59,22 +108,37 @@ export default class ToolRegistry {
|
|
|
59
108
|
}
|
|
60
109
|
}
|
|
61
110
|
|
|
62
|
-
get actTools() {
|
|
63
|
-
return new Set(
|
|
64
|
-
[...this.#tools.entries()]
|
|
65
|
-
.filter(([, def]) => def.category === "act")
|
|
66
|
-
.map(([name]) => name),
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
111
|
get names() {
|
|
71
|
-
return [...this.#tools.keys()];
|
|
112
|
+
return sortByPriority([...this.#tools.keys()]);
|
|
72
113
|
}
|
|
73
114
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Compute the active tool set for a loop.
|
|
117
|
+
* All exclusions — mode, flags — handled here. One mechanism.
|
|
118
|
+
*/
|
|
119
|
+
resolveForLoop(
|
|
120
|
+
mode,
|
|
121
|
+
{ noInteraction = false, noWeb = false, noBench = false } = {},
|
|
122
|
+
) {
|
|
123
|
+
const excluded = new Set();
|
|
124
|
+
if (mode === "ask") excluded.add("sh");
|
|
125
|
+
if (mode === "panic") {
|
|
126
|
+
excluded.add("sh");
|
|
127
|
+
excluded.add("env");
|
|
128
|
+
excluded.add("search");
|
|
129
|
+
excluded.add("ask_user");
|
|
130
|
+
}
|
|
131
|
+
if (noInteraction) excluded.add("ask_user");
|
|
132
|
+
if (noWeb) excluded.add("search");
|
|
133
|
+
if (noBench) {
|
|
134
|
+
excluded.add("ask_user");
|
|
135
|
+
excluded.add("env");
|
|
136
|
+
excluded.add("sh");
|
|
137
|
+
}
|
|
138
|
+
const names = sortByPriority(
|
|
139
|
+
[...this.#tools.keys()].filter((n) => !excluded.has(n)),
|
|
140
|
+
);
|
|
141
|
+
return new Set(names);
|
|
78
142
|
}
|
|
79
143
|
|
|
80
144
|
entries() {
|
package/src/llm/LlmProvider.js
CHANGED
|
@@ -90,18 +90,37 @@ export default class LlmProvider {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
async getContextSize(model) {
|
|
93
|
+
// DB is the authority — check models table first
|
|
94
|
+
if (this.#db) {
|
|
95
|
+
const row = await this.#db.get_model_by_alias.get({ alias: model });
|
|
96
|
+
if (row?.context_length) return row.context_length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Fall back to API query
|
|
93
100
|
const resolvedModel = await this.resolve(model);
|
|
101
|
+
let size;
|
|
94
102
|
if (resolvedModel.startsWith("ollama/")) {
|
|
95
103
|
const localModel = resolvedModel.replace("ollama/", "");
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
if (resolvedModel.startsWith("x.ai/")) {
|
|
104
|
+
size = await this.#getOllama().getContextSize(localModel);
|
|
105
|
+
} else if (resolvedModel.startsWith("openai/")) {
|
|
106
|
+
size = await this.#getOpenAi().getContextSize(resolvedModel);
|
|
107
|
+
} else if (resolvedModel.startsWith("x.ai/")) {
|
|
102
108
|
const localModel = resolvedModel.replace("x.ai/", "");
|
|
103
|
-
|
|
109
|
+
size = await this.#getXai().getContextSize(localModel);
|
|
110
|
+
} else {
|
|
111
|
+
size = await this.#getOpenRouter().getContextSize(resolvedModel);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Cache back to DB for next time
|
|
115
|
+
if (this.#db && size) {
|
|
116
|
+
await this.#db.update_model_context_length
|
|
117
|
+
.run({
|
|
118
|
+
alias: model,
|
|
119
|
+
context_length: size,
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {});
|
|
104
122
|
}
|
|
105
|
-
|
|
123
|
+
|
|
124
|
+
return size;
|
|
106
125
|
}
|
|
107
126
|
}
|
package/src/llm/OpenAiClient.js
CHANGED
|
@@ -49,6 +49,12 @@ export default class OpenAiClient {
|
|
|
49
49
|
);
|
|
50
50
|
msg.reasoning_content =
|
|
51
51
|
parts.length > 0 ? [...new Set(parts)].join("\n") : null;
|
|
52
|
+
|
|
53
|
+
if (process.env.RUMMY_DEBUG === "true" && msg.reasoning_content) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`[RUMMY] Reasoning (${msg.reasoning_content.length} chars): ${msg.reasoning_content.slice(0, 120)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
return data;
|
|
@@ -59,6 +65,20 @@ export default class OpenAiClient {
|
|
|
59
65
|
const headers = { "Content-Type": "application/json" };
|
|
60
66
|
if (this.#apiKey) headers.Authorization = `Bearer ${this.#apiKey}`;
|
|
61
67
|
|
|
68
|
+
// Try /props first — llama.cpp exposes runtime n_ctx here
|
|
69
|
+
try {
|
|
70
|
+
const propsResponse = await fetch(`${this.#baseUrl}/props`, {
|
|
71
|
+
headers,
|
|
72
|
+
signal: AbortSignal.timeout(timeout),
|
|
73
|
+
});
|
|
74
|
+
if (propsResponse.ok) {
|
|
75
|
+
const props = await propsResponse.json();
|
|
76
|
+
const runtimeCtx = props?.default_generation_settings?.n_ctx;
|
|
77
|
+
if (runtimeCtx) return runtimeCtx;
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
|
|
81
|
+
// Fall back to /v1/models for training context
|
|
62
82
|
const response = await fetch(`${this.#baseUrl}/v1/models`, {
|
|
63
83
|
headers,
|
|
64
84
|
signal: AbortSignal.timeout(timeout),
|
|
@@ -72,7 +72,29 @@ export default class OpenRouterClient {
|
|
|
72
72
|
return data;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
#contextCache = new Map();
|
|
76
|
+
|
|
77
|
+
async getContextSize(model) {
|
|
78
|
+
if (process.env.RUMMY_CONTEXT_SIZE)
|
|
79
|
+
return Number(process.env.RUMMY_CONTEXT_SIZE);
|
|
80
|
+
|
|
81
|
+
if (this.#contextCache.has(model)) return this.#contextCache.get(model);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${this.#baseUrl}/models`, {
|
|
85
|
+
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
86
|
+
signal: AbortSignal.timeout(5000),
|
|
87
|
+
});
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
const entry = data.data?.find((m) => m.id === model);
|
|
91
|
+
if (entry?.context_length) {
|
|
92
|
+
this.#contextCache.set(model, entry.context_length);
|
|
93
|
+
return entry.context_length;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
return DEFAULT_CONTEXT_SIZE;
|
|
77
99
|
}
|
|
78
100
|
}
|
package/src/llm/XaiClient.js
CHANGED
|
@@ -3,6 +3,7 @@ import msg from "../agent/messages.js";
|
|
|
3
3
|
export default class XaiClient {
|
|
4
4
|
#baseUrl;
|
|
5
5
|
#apiKey;
|
|
6
|
+
#contextCache = new Map();
|
|
6
7
|
|
|
7
8
|
constructor(baseUrl, apiKey) {
|
|
8
9
|
this.#baseUrl = baseUrl;
|
|
@@ -107,7 +108,51 @@ export default class XaiClient {
|
|
|
107
108
|
);
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
async getContextSize(
|
|
111
|
-
|
|
111
|
+
async getContextSize(model) {
|
|
112
|
+
if (this.#contextCache.has(model)) return this.#contextCache.get(model);
|
|
113
|
+
|
|
114
|
+
if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
|
|
115
|
+
|
|
116
|
+
// Query xAI models endpoint
|
|
117
|
+
const modelsUrl = this.#baseUrl.replace(/\/responses$/, "/models");
|
|
118
|
+
const res = await fetch(modelsUrl, {
|
|
119
|
+
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
120
|
+
signal: AbortSignal.timeout(5000),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (res.ok) {
|
|
124
|
+
const data = await res.json();
|
|
125
|
+
const models = data.data || data.models || [];
|
|
126
|
+
const entry = models.find(
|
|
127
|
+
(m) => m.id === model || `${m.id}-latest` === model,
|
|
128
|
+
);
|
|
129
|
+
if (entry?.context_length) {
|
|
130
|
+
this.#contextCache.set(model, entry.context_length);
|
|
131
|
+
return entry.context_length;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Try /v1/language-models for richer metadata
|
|
136
|
+
const langUrl = this.#baseUrl.replace(
|
|
137
|
+
/\/responses$/,
|
|
138
|
+
`/language-models/${model}`,
|
|
139
|
+
);
|
|
140
|
+
const langRes = await fetch(langUrl, {
|
|
141
|
+
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
142
|
+
signal: AbortSignal.timeout(5000),
|
|
143
|
+
}).catch(() => null);
|
|
144
|
+
|
|
145
|
+
if (langRes?.ok) {
|
|
146
|
+
const langData = await langRes.json();
|
|
147
|
+
if (langData?.context_length) {
|
|
148
|
+
this.#contextCache.set(model, langData.context_length);
|
|
149
|
+
return langData.context_length;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Cannot determine context size for xAI model "${model}". ` +
|
|
155
|
+
"Register the model with addModel(contextLength) or set context_length in the models table.",
|
|
156
|
+
);
|
|
112
157
|
}
|
|
113
158
|
}
|
|
@@ -5,9 +5,8 @@ Presents a question to the user with optional multiple-choice answers.
|
|
|
5
5
|
## Registration
|
|
6
6
|
|
|
7
7
|
- **Tool**: `ask_user`
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **Handler**: Parses options (semicolon or comma delimited) and upserts a `proposed` entry awaiting user response.
|
|
8
|
+
- **Category**: `logging`
|
|
9
|
+
- **Handler**: Parses options (semicolon or comma delimited) and upserts at status 202 (proposed) awaiting user response.
|
|
11
10
|
|
|
12
11
|
## Projection
|
|
13
12
|
|
|
@@ -15,4 +14,5 @@ Shows the question and answer attributes.
|
|
|
15
14
|
|
|
16
15
|
## Behavior
|
|
17
16
|
|
|
18
|
-
Options are split by semicolons first, falling back to commas. The entry
|
|
17
|
+
Options are split by semicolons first, falling back to commas. The entry
|
|
18
|
+
stays at status 202 until resolved by the client via `run/resolve`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import docs from "./ask_userDoc.js";
|
|
2
2
|
|
|
3
3
|
export default class AskUser {
|
|
4
4
|
#core;
|
|
@@ -9,14 +9,14 @@ export default class AskUser {
|
|
|
9
9
|
core.on("handler", this.handler.bind(this));
|
|
10
10
|
core.on("full", this.full.bind(this));
|
|
11
11
|
core.on("summary", this.summary.bind(this));
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
);
|
|
12
|
+
core.filter("instructions.toolDocs", async (docsMap) => {
|
|
13
|
+
docsMap.ask_user = docs;
|
|
14
|
+
return docsMap;
|
|
15
|
+
});
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async handler(entry, rummy) {
|
|
19
|
-
const { entries: store, sequence: turn, runId } = rummy;
|
|
19
|
+
const { entries: store, sequence: turn, runId, loopId } = rummy;
|
|
20
20
|
const { question, options: rawOptions } = entry.attributes;
|
|
21
21
|
|
|
22
22
|
const optionText = rawOptions || entry.body || "";
|
|
@@ -28,8 +28,9 @@ export default class AskUser {
|
|
|
28
28
|
.filter(Boolean)
|
|
29
29
|
: [];
|
|
30
30
|
|
|
31
|
-
await store.upsert(runId, turn, entry.resultPath, entry.body,
|
|
31
|
+
await store.upsert(runId, turn, entry.resultPath, entry.body, 202, {
|
|
32
32
|
attributes: { question, options },
|
|
33
|
+
loopId,
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Tool doc for <ask_user>. Each entry: [text, rationale].
|
|
2
|
+
// Text goes to the model. Rationale stays in source.
|
|
3
|
+
// Changing ANY line requires reading ALL rationales first.
|
|
4
|
+
const LINES = [
|
|
5
|
+
// --- Syntax: question attr + options in body
|
|
6
|
+
['## <ask_user question="[Question?]">[option1; option2; ...]</ask_user>'],
|
|
7
|
+
|
|
8
|
+
// --- Constraints FIRST: frames correct usage before examples
|
|
9
|
+
[
|
|
10
|
+
"* YOU SHOULD use for decisions, preferences, or approvals the user must make",
|
|
11
|
+
"Positive framing. Shows what ask_user IS for, not just what it isn't.",
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
"* YOU SHOULD use <get> to find information before asking the user",
|
|
15
|
+
"Gentle redirect. Encourages self-sufficiency without forbidding interaction.",
|
|
16
|
+
],
|
|
17
|
+
|
|
18
|
+
// --- Examples: genuine decision points where user input is valuable
|
|
19
|
+
[
|
|
20
|
+
'Example: <ask_user question="Which test framework?">Mocha; Jest; Node Native</ask_user>',
|
|
21
|
+
"Preference decision. Model truly cannot know this without asking.",
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
'Example: <ask_user question="Deploy to staging or production?">staging; production</ask_user>',
|
|
25
|
+
"Consequential action. Shows ask_user for high-stakes choices.",
|
|
26
|
+
],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export default LINES.map(([text]) => text).join("\n");
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { countTokens } from "../../agent/tokens.js";
|
|
2
|
+
|
|
3
|
+
export class BudgetExceeded extends Error {
|
|
4
|
+
constructor(path, requested, remaining) {
|
|
5
|
+
super(
|
|
6
|
+
`Budget exceeded: ${path} needs ${requested} tokens, ${remaining} remaining`,
|
|
7
|
+
);
|
|
8
|
+
this.name = "BudgetExceeded";
|
|
9
|
+
this.status = 413;
|
|
10
|
+
this.path = path;
|
|
11
|
+
this.requested = requested;
|
|
12
|
+
this.remaining = remaining;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default class BudgetGuard {
|
|
17
|
+
#ceiling;
|
|
18
|
+
#baseline;
|
|
19
|
+
#spent;
|
|
20
|
+
#tripped;
|
|
21
|
+
#tripSource;
|
|
22
|
+
|
|
23
|
+
constructor(ceiling, baseline) {
|
|
24
|
+
this.#ceiling = ceiling ?? null;
|
|
25
|
+
this.#baseline = baseline;
|
|
26
|
+
this.#spent = 0;
|
|
27
|
+
this.#tripped = false;
|
|
28
|
+
this.#tripSource = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get isTripped() {
|
|
32
|
+
return this.#tripped;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get tripSource() {
|
|
36
|
+
return this.#tripSource;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get remaining() {
|
|
40
|
+
if (this.#ceiling === null) return Infinity;
|
|
41
|
+
return this.#ceiling - this.#baseline - this.#spent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get spent() {
|
|
45
|
+
return this.#spent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
check(tokens, path) {
|
|
49
|
+
if (this.#ceiling === null) return;
|
|
50
|
+
if (this.#tripped) throw new BudgetExceeded(path, tokens, 0);
|
|
51
|
+
if (tokens <= 0) return;
|
|
52
|
+
const remaining = this.remaining;
|
|
53
|
+
if (tokens > remaining) throw new BudgetExceeded(path, tokens, remaining);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
charge(tokens) {
|
|
57
|
+
if (tokens > 0) this.#spent += tokens;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
trip(source) {
|
|
61
|
+
this.#tripped = true;
|
|
62
|
+
this.#tripSource = source;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compute the token delta for an upsert. New entry = full cost.
|
|
67
|
+
* Update = difference between new and old body.
|
|
68
|
+
*/
|
|
69
|
+
static delta(newBody, existingBody) {
|
|
70
|
+
const newTokens = countTokens(newBody);
|
|
71
|
+
const oldTokens = existingBody ? countTokens(existingBody) : 0;
|
|
72
|
+
return newTokens - oldTokens;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# budget
|
|
2
|
+
|
|
3
|
+
Context ceiling enforcement and panic mode recovery.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
- **budget.js** — Plugin. Pre-LLM enforce, BudgetGuard activation,
|
|
8
|
+
panic prompt generation.
|
|
9
|
+
- **BudgetGuard.js** — Write-layer gate. Installed on KnownStore during
|
|
10
|
+
dispatch. Checks token delta on every upsert, promote, and body update.
|
|
11
|
+
|
|
12
|
+
## Registration
|
|
13
|
+
|
|
14
|
+
- **Hook**: `hooks.budget.enforce` — pre-LLM ceiling check.
|
|
15
|
+
- **Hook**: `hooks.budget.activate(store, contextSize, assembledTokens)` — install guard.
|
|
16
|
+
- **Hook**: `hooks.budget.deactivate(store)` — remove guard.
|
|
17
|
+
- **Hook**: `hooks.budget.panicPrompt({ shortfall, assembledTokens, contextSize })` — generate panic prompt.
|
|
18
|
+
|
|
19
|
+
## Budget Contract
|
|
20
|
+
|
|
21
|
+
`contextSize` is the ceiling. `countTokens()` is the measurement.
|
|
22
|
+
Over = 413. Under = 200. No margins.
|
|
23
|
+
|
|
24
|
+
## BudgetGuard
|
|
25
|
+
|
|
26
|
+
Installed on KnownStore by TurnExecutor before dispatch, cleared in
|
|
27
|
+
`finally`. Gates `upsert()`, `promoteByPattern()`, `updateBodyByPattern()`.
|
|
28
|
+
|
|
29
|
+
Exemptions: `status >= 400` (error entries), `model_visible = 0` (audit),
|
|
30
|
+
`fidelity = "archive"` (not in context).
|
|
31
|
+
|
|
32
|
+
On first violation: `BudgetExceeded` thrown, guard trips, all subsequent
|
|
33
|
+
writes fail. TurnExecutor catches per-tool, writes 413 result entry.
|
|
34
|
+
|
|
35
|
+
## Panic Mode
|
|
36
|
+
|
|
37
|
+
When a new prompt exceeds the ceiling, AgentLoop enqueues a panic loop.
|
|
38
|
+
The model receives the exact shortfall and must free space using core
|
|
39
|
+
tools (get, set, known, unknown, rm, mv, cp, summarize, update).
|
|
40
|
+
Excluded: sh, env, search, ask_user.
|
|
41
|
+
|
|
42
|
+
Strike system: 3 consecutive turns without context reduction = hard 413.
|
|
43
|
+
Any reduction resets the counter. One panic attempt per drain cycle.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { countTokens } from "../../agent/tokens.js";
|
|
2
|
+
import BudgetGuard, { BudgetExceeded } from "./BudgetGuard.js";
|
|
3
|
+
|
|
4
|
+
function measureMessages(messages) {
|
|
5
|
+
return messages.reduce((sum, m) => sum + countTokens(m.content), 0);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export { BudgetExceeded };
|
|
9
|
+
|
|
10
|
+
export default class Budget {
|
|
11
|
+
#core;
|
|
12
|
+
|
|
13
|
+
constructor(core) {
|
|
14
|
+
this.#core = core;
|
|
15
|
+
core.hooks.budget = {
|
|
16
|
+
enforce: this.enforce.bind(this),
|
|
17
|
+
activate: this.activate.bind(this),
|
|
18
|
+
deactivate: this.deactivate.bind(this),
|
|
19
|
+
panicPrompt: Budget.panicPrompt,
|
|
20
|
+
BudgetExceeded,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static panicPrompt({ assembledTokens, contextSize }) {
|
|
25
|
+
const target = Math.floor(contextSize * 0.75);
|
|
26
|
+
const mustFree = assembledTokens - target;
|
|
27
|
+
return [
|
|
28
|
+
`CONTEXT OVERFLOW: ${assembledTokens} tokens, ceiling ${contextSize}.`,
|
|
29
|
+
`YOU MUST free ${mustFree} tokens to get below ${target} (75%).`,
|
|
30
|
+
"YOU MUST NOT load or create new content. Only reduce.",
|
|
31
|
+
"",
|
|
32
|
+
"<knowns> above shows each entry with its token count.",
|
|
33
|
+
"Target the largest entries first.",
|
|
34
|
+
'<rm path="..."/> to delete entries you no longer need.',
|
|
35
|
+
'<set path="..." fidelity="summary" summary="keywords"/> to compress.',
|
|
36
|
+
'<set path="..." fidelity="archive"/> to archive out of context.',
|
|
37
|
+
"<summarize/> when done. <update/> if still working.",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async enforce({ contextSize, messages, rows }) {
|
|
42
|
+
if (!contextSize) {
|
|
43
|
+
return { messages, rows, demoted: [], assembledTokens: 0, status: 200 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const assembledTokens = measureMessages(messages);
|
|
47
|
+
|
|
48
|
+
console.warn(
|
|
49
|
+
`[RUMMY] Budget enforce: ${assembledTokens} tokens, ceiling ${contextSize}, ${rows.length} rows`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (assembledTokens > contextSize) {
|
|
53
|
+
const overflow = assembledTokens - contextSize;
|
|
54
|
+
console.warn(
|
|
55
|
+
`[RUMMY] Budget 413: ${assembledTokens} tokens > ${contextSize} ceiling (${overflow} over)`,
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
messages,
|
|
59
|
+
rows,
|
|
60
|
+
demoted: [],
|
|
61
|
+
assembledTokens,
|
|
62
|
+
status: 413,
|
|
63
|
+
overflow,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { messages, rows, demoted: [], assembledTokens, status: 200 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
activate(store, contextSize, assembledTokens) {
|
|
71
|
+
const guard = new BudgetGuard(contextSize, assembledTokens);
|
|
72
|
+
store.budgetGuard = guard;
|
|
73
|
+
return guard;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
deactivate(store) {
|
|
77
|
+
store.budgetGuard = null;
|
|
78
|
+
}
|
|
79
|
+
}
|