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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Control layer for AI agents: check payments, emails, logins and destructive actions against your policies",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
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
- 'You MUST also call the evaluate_model tool when you begin a substantial task and whenever you switch model or effort level partway through it, giving the exact model id and how many tokens you expect to use. Do not run the task 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. Then, when the task finishes, 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.',
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 when you start a task and whenever you switch model or effort level mid-task. ' +
272
- 'Returns an audit_id. Keep it: you must pass it to report_model_usage when the task finishes.',
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 a task ACTUALLY used, once it has finished. ' +
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 the task finishes, call report_model_usage(audit_id="${audit_id}", actual_tokens=<real total>).`;
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
+ });