troxy-cli 1.16.0 → 1.18.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/package.json +1 -1
- package/src/mcp-server.js +28 -6
- package/src/tests/model-checkpoint.test.js +113 -0
package/package.json
CHANGED
package/src/mcp-server.js
CHANGED
|
@@ -51,7 +51,27 @@ export async function runMcp() {
|
|
|
51
51
|
// its own is the agent's own prediction, and the dashboard labels every
|
|
52
52
|
// agent that never reports back as unverified, so the instruction has
|
|
53
53
|
// to make the second call as non-optional as the first.
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// The previous wording gated this on whether the task felt big
|
|
56
|
+
// enough, which was a judgment call, not a rule. Driven live: the
|
|
57
|
+
// same agent, same session, called evaluate_model to review two
|
|
58
|
+
// source files, then minutes later pulled real data from an actual
|
|
59
|
+
// connected business (orders, customer messages, an artifact) and
|
|
60
|
+
// called nothing. Both were real work by any reasonable reading. The
|
|
61
|
+
// old wording meant whatever the model felt like in the moment, and
|
|
62
|
+
// Token Optimization is supposed to be a precise record, not a mood.
|
|
63
|
+
// The trigger below is mechanical instead: did this turn use a tool,
|
|
64
|
+
// yes or no. That is a fact you can check, not a judgment you make.
|
|
65
|
+
'You MUST also call the evaluate_model tool at the start of ANY turn where you will use one or more tools of any kind — reading or writing a file, running a command, browsing, calling an external API, anything beyond a plain text reply — and whenever you switch model or effort level partway through the turn. This is not a judgment call about how the turn feels: if you are about to use a tool, call evaluate_model first, every time, regardless of how small the turn seems, and WAIT for the decision before proceeding. Give the exact model id and how many tokens you expect to use. Do not use any tool without first receiving an ALLOW or NOTIFY decision; if BLOCK, pick a cheaper or smaller model and call evaluate_model again with the new id; if ESCALATE, call wait_for_approval and do not proceed until it resolves. Then, at the end of that same turn, you MUST call report_model_usage with the audit_id from that decision and the real token total from your model API. Both halves are required: without the second call Troxy has only your prediction, and the account owner is shown that this agent\'s costs are unverified. Report the true number even when it is far above your estimate. ' +
|
|
66
|
+
// A turn that is PURE conversation, no tool use at all, still gets
|
|
67
|
+
// logged, just not gated. Blocking a chat reply on a network round
|
|
68
|
+
// trip protects against a risk that is vanishingly rare in practice
|
|
69
|
+
// (almost nobody writes a policy to block message content), and the
|
|
70
|
+
// cost of blocking anyway is a slower product on every single message.
|
|
71
|
+
// So chat calls the same two tools, in the same order, but treats the
|
|
72
|
+
// decision as a record rather than a gate: reply to the user first,
|
|
73
|
+
// report afterward, never hold the reply for Troxy's answer.
|
|
74
|
+
'A turn that is pure conversation, with no tool use at all, is different: you still call evaluate_model and report_model_usage for it, but you do NOT wait for or act on the decision before replying. Reply to the user first. Then, as the last step of that same turn (or the first step of your next turn if your environment does not let you act again after producing a reply), call evaluate_model with the model and your best token estimate, and call report_model_usage with the real total right after — same two tools as above, just not gating. If the decision comes back BLOCK or ESCALATE for a chat turn, do not retry with a different model and do not poll wait_for_approval: the reply has already been sent, there is nothing left to gate, and the decision is recorded for the account owner\'s visibility rather than enforced in the moment. Chat usage still counts toward every rolling spend and token limit, so it still shapes what the next tool-use call is allowed to do.',
|
|
55
75
|
},
|
|
56
76
|
{ capabilities: { tools: {} } },
|
|
57
77
|
);
|
|
@@ -268,8 +288,10 @@ export async function runMcp() {
|
|
|
268
288
|
description:
|
|
269
289
|
'Evaluate whether running a task on a given AI model should be allowed, blocked, or escalated ' +
|
|
270
290
|
'based on your Troxy policies, and record what it is expected to cost. ' +
|
|
271
|
-
'Call this
|
|
272
|
-
'
|
|
291
|
+
'Call this on every turn: at the start, before proceeding, if you will use a tool of any kind, or whenever you switch model or effort level mid-turn; ' +
|
|
292
|
+
'or after replying, without waiting for the decision, if the turn was pure conversation with no tool use. ' +
|
|
293
|
+
'Tool use gates on the decision; chat only records it. ' +
|
|
294
|
+
'Returns an audit_id. Keep it: you must pass it to report_model_usage when the turn finishes.',
|
|
273
295
|
inputSchema: {
|
|
274
296
|
type: 'object',
|
|
275
297
|
required: ['model'],
|
|
@@ -309,8 +331,8 @@ export async function runMcp() {
|
|
|
309
331
|
{
|
|
310
332
|
name: 'report_model_usage',
|
|
311
333
|
description:
|
|
312
|
-
'Report how many tokens
|
|
313
|
-
'Call this after every ALLOW or NOTIFY from evaluate_model, using the audit_id it returned. ' +
|
|
334
|
+
'Report how many tokens the turn ACTUALLY used, once it has finished. ' +
|
|
335
|
+
'Call this after every ALLOW or NOTIFY from evaluate_model, using the audit_id it returned — no judgment call, every approved evaluate_model gets a matching report. ' +
|
|
314
336
|
'Without it Troxy only has your prediction, and the account owner is told, per agent, that its figures are unverified. ' +
|
|
315
337
|
'Report the real number even when it is far above what you estimated: the gap is the point.',
|
|
316
338
|
inputSchema: {
|
|
@@ -489,7 +511,7 @@ export async function runMcp() {
|
|
|
489
511
|
// Every non-blocked branch repeats the audit_id and the instruction to
|
|
490
512
|
// report back. Half the value of this checkpoint is the second call, and
|
|
491
513
|
// an agent that is only told "approved" has no reason to make it.
|
|
492
|
-
const followUp = ` When
|
|
514
|
+
const followUp = ` When this turn finishes, call report_model_usage(audit_id="${audit_id}", actual_tokens=<real total>).`;
|
|
493
515
|
let modelText;
|
|
494
516
|
switch (decision) {
|
|
495
517
|
case 'ALLOW':
|
|
@@ -132,3 +132,116 @@ test('the version is read from package.json, not hardcoded', () => {
|
|
|
132
132
|
'CLI_VERSION is not derived from package.json',
|
|
133
133
|
);
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
test('the checkpoint trigger is mechanical, not a judgment call', () => {
|
|
137
|
+
// "When you begin a substantial task" was tried first and driven live: the
|
|
138
|
+
// same agent, same session, called evaluate_model to review two source
|
|
139
|
+
// files, then minutes later pulled real data from an actual connected
|
|
140
|
+
// business (orders, customer messages, an artifact) and called nothing.
|
|
141
|
+
// Both were real work. "Substantial" meant whatever the model felt like in
|
|
142
|
+
// the moment, which is not what a precise cost record can be built on.
|
|
143
|
+
// Sliced from the opening quote of the instructions STRING, not the
|
|
144
|
+
// instructions: field or file start, so the explanatory code comment
|
|
145
|
+
// above it (which deliberately quotes the retired word for context) does
|
|
146
|
+
// not trip this check.
|
|
147
|
+
const instructions = src.slice(src.indexOf("'You are connected to Troxy"), src.indexOf('capabilities'));
|
|
148
|
+
assert.ok(
|
|
149
|
+
!/substantial/i.test(instructions),
|
|
150
|
+
'the instructions still gate the checkpoint on "substantial", a judgment '
|
|
151
|
+
+ 'call proven inconsistent on the same agent in the same session',
|
|
152
|
+
);
|
|
153
|
+
assert.ok(
|
|
154
|
+
/tool/i.test(instructions) && /every time/i.test(instructions),
|
|
155
|
+
'the instructions do not state a mechanical, checkable trigger (tool use) '
|
|
156
|
+
+ 'to replace the judgment call that was removed',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('evaluate_model\'s own tool description matches the mechanical trigger', () => {
|
|
161
|
+
// The top-level instructions are what an agent reads once; the tool
|
|
162
|
+
// description is what it sees every time it considers calling this
|
|
163
|
+
// specific tool. Both have to agree, or whichever one it reads last wins.
|
|
164
|
+
const schema = src.slice(src.indexOf("name: 'evaluate_model'"));
|
|
165
|
+
const desc = schema.slice(0, schema.indexOf('inputSchema'));
|
|
166
|
+
assert.ok(!/substantial/i.test(desc), 'the tool description still says "substantial"');
|
|
167
|
+
assert.ok(/tool/i.test(desc), 'the tool description does not mention the tool-use trigger');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('pure chat still reports usage, it just does not gate on the decision', () => {
|
|
171
|
+
// The first version of this fix (tool-use = mechanical trigger) left chat
|
|
172
|
+
// unreported entirely, on the reasoning that chat isn't "work." That
|
|
173
|
+
// reasoning doesn't survive contact with the goal: chat still costs real
|
|
174
|
+
// tokens, and a page that silently misses all chat-only usage is not a
|
|
175
|
+
// precise record, it just moved the same gap somewhere less visible.
|
|
176
|
+
const instructions = src.slice(src.indexOf("'You are connected to Troxy"), src.indexOf('capabilities'));
|
|
177
|
+
const chatSeg = instructions.slice(instructions.indexOf('A turn that is pure conversation'));
|
|
178
|
+
assert.ok(chatSeg, 'no instruction exists for the pure-conversation case at all');
|
|
179
|
+
assert.ok(
|
|
180
|
+
/still call evaluate_model and report_model_usage/.test(chatSeg),
|
|
181
|
+
'chat turns are not instructed to call the checkpoint tools at all, so '
|
|
182
|
+
+ 'chat-only usage is invisible on Token Optimization',
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('chat is told explicitly not to block the reply on the decision', () => {
|
|
187
|
+
// The whole point of treating chat differently from tool use is that a
|
|
188
|
+
// human is waiting on the reply. If the instructions still say "wait for
|
|
189
|
+
// the decision" for chat, this fix adds latency to every message for no
|
|
190
|
+
// behavioural gain over the tool-use path it was meant to avoid copying.
|
|
191
|
+
const instructions = src.slice(src.indexOf("'You are connected to Troxy"), src.indexOf('capabilities'));
|
|
192
|
+
const chatSeg = instructions.slice(instructions.indexOf('A turn that is pure conversation'));
|
|
193
|
+
assert.ok(
|
|
194
|
+
/do NOT wait for or act on the decision before replying/.test(chatSeg),
|
|
195
|
+
'chat instructions do not say to reply before waiting on the checkpoint',
|
|
196
|
+
);
|
|
197
|
+
assert.ok(
|
|
198
|
+
/Reply to the user first/.test(chatSeg),
|
|
199
|
+
'chat instructions do not establish reply-then-report ordering',
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a BLOCK or ESCALATE on a chat turn does not trigger retry or polling', () => {
|
|
204
|
+
// Retrying with a cheaper model, or polling wait_for_approval, are the
|
|
205
|
+
// tool-use responses to BLOCK/ESCALATE. For chat the reply is already sent
|
|
206
|
+
// by the time the decision comes back, so both of those actions are
|
|
207
|
+
// meaningless and would just make the agent stall on a message that
|
|
208
|
+
// already went out.
|
|
209
|
+
const instructions = src.slice(src.indexOf("'You are connected to Troxy"), src.indexOf('capabilities'));
|
|
210
|
+
const chatSeg = instructions.slice(instructions.indexOf('A turn that is pure conversation'));
|
|
211
|
+
assert.ok(
|
|
212
|
+
/do not retry with a different model and do not poll wait_for_approval/.test(chatSeg),
|
|
213
|
+
'chat instructions do not rule out the tool-use recovery actions, which '
|
|
214
|
+
+ 'would stall a reply that has already been sent',
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('chat usage still feeds the rolling limits that gate tool use', () => {
|
|
219
|
+
// Not gating chat in real time is only an acceptable trade if chat spend
|
|
220
|
+
// still counts toward spend_per_hour / tokens_per_day, so a chat-heavy
|
|
221
|
+
// agent still gets caught the moment it tries to use a tool. Otherwise
|
|
222
|
+
// "we don't block chat" quietly becomes "chat spend is uncapped."
|
|
223
|
+
const instructions = src.slice(src.indexOf("'You are connected to Troxy"), src.indexOf('capabilities'));
|
|
224
|
+
const chatSeg = instructions.slice(instructions.indexOf('A turn that is pure conversation'));
|
|
225
|
+
assert.ok(
|
|
226
|
+
/still counts toward every rolling spend and token limit/.test(chatSeg),
|
|
227
|
+
'nothing tells the agent that unblocked chat spend still accumulates '
|
|
228
|
+
+ 'toward the limits a later tool-use call is checked against',
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('no new tool was introduced for the chat path', () => {
|
|
233
|
+
// The backend already supports this: report_model_usage looks up its row
|
|
234
|
+
// by id/user/agent, never by decision, so it works identically whether the
|
|
235
|
+
// row says ALLOW, BLOCK, or ESCALATE. Reusing evaluate_model +
|
|
236
|
+
// report_model_usage for chat means zero backend changes and zero new
|
|
237
|
+
// surface area to get wrong; a bespoke chat-logging tool would duplicate a
|
|
238
|
+
// path that is already tested end to end.
|
|
239
|
+
// Scoped to the tools array, not the whole file: the Server constructor
|
|
240
|
+
// also has a top-level `name: 'troxy'` for the server itself, which the
|
|
241
|
+
// unscoped regex was catching as a ninth "tool".
|
|
242
|
+
const toolsSeg = src.slice(src.indexOf('tools: ['), src.indexOf('server.setRequestHandler(CallToolRequestSchema'));
|
|
243
|
+
const toolNames = [...toolsSeg.matchAll(/name: '([a-z_]+)'/g)].map(m => m[1]);
|
|
244
|
+
const unique = new Set(toolNames);
|
|
245
|
+
assert.strictEqual(unique.size, 8, 'a tool was added or removed for this change');
|
|
246
|
+
assert.ok(unique.has('evaluate_model') && unique.has('report_model_usage'));
|
|
247
|
+
});
|