tewip-ai 0.76.0 → 0.77.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/README.md +8 -0
- package/dist/engine/agent/agent-command.js +10 -52
- package/dist/engine/agent/scan.js +84 -0
- package/dist/engine/agent/tool-tests.js +87 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1252,6 +1252,14 @@ offline, in CI, before anything runs.
|
|
|
1252
1252
|
because that is the difference between a wrong answer and a tool call somebody else chose. On
|
|
1253
1253
|
1,691 files of real agent code: **zero findings**, which is the precision claim; recall is
|
|
1254
1254
|
measured against planted examples and said to be.
|
|
1255
|
+
- **Which of these tools anybody wrote a test about.** Not "does this project have tests" - every
|
|
1256
|
+
project has tests - but whether *this* tool is mentioned in any of them, and especially the ones
|
|
1257
|
+
that cannot be undone. A name too short or too common to look for is reported as undecidable
|
|
1258
|
+
rather than counted as covered. And the statement is made precisely in both directions: a tool
|
|
1259
|
+
missing from the list is not a tool whose behaviour is untested, it is a tool nothing tests **by
|
|
1260
|
+
that name** - the wiring between the name the model sees and the code behind it. Tewip finds 8 of
|
|
1261
|
+
its own 17 that way. Also names which eval framework the project uses, and which it looked for
|
|
1262
|
+
when it finds none.
|
|
1255
1263
|
- **What the coding agent installed here was permitted to do.** `.claude/settings.json` and
|
|
1256
1264
|
`.mcp.json` are committed, anybody with write access can change them, and neither turns up in a
|
|
1257
1265
|
review the way a source file does. Tewip names the approval gate switched off
|
|
@@ -1,56 +1,13 @@
|
|
|
1
1
|
// `tewip agent`: what this agent is allowed to do, and what stands between it and the real world.
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { CAPABILITY_MEANS, FRAMEWORKS_READ, IRREVERSIBLE, trifectaIn } from './tools.js';
|
|
4
|
+
import {} from './prompt-flow.js';
|
|
5
|
+
import { configSection } from './claude-config.js';
|
|
6
|
+
import { coverageSection } from './tool-tests.js';
|
|
7
|
+
import { GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
|
|
8
|
+
import { scanAgent } from './scan.js';
|
|
9
|
+
export { scanAgent };
|
|
9
10
|
const NL = String.fromCharCode(10);
|
|
10
|
-
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
11
|
-
/**
|
|
12
|
-
* What is not the shipped agent.
|
|
13
|
-
*
|
|
14
|
-
* The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
|
|
15
|
-
* documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
|
|
16
|
-
* all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
|
|
17
|
-
* whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
|
|
18
|
-
* at the front of the file. A list of an agent's tools that is actually a list of test doubles is
|
|
19
|
-
* worse than no list.
|
|
20
|
-
*
|
|
21
|
-
* `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
|
|
22
|
-
*/
|
|
23
|
-
const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|examples?|samples?|docs?|fixtures?|node_modules|\.venv|venv)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|(?:^|[\\/])test_[^\\/]*\.py$/i;
|
|
24
|
-
export function scanAgent(root) {
|
|
25
|
-
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
26
|
-
const tools = [];
|
|
27
|
-
const injections = [];
|
|
28
|
-
const guards = [];
|
|
29
|
-
let emptyBudget = null;
|
|
30
|
-
let filesRead = 0;
|
|
31
|
-
for (const path of scan.files) {
|
|
32
|
-
const where = relative(root, path).replace(/\\/g, '/');
|
|
33
|
-
if (NOT_SHIPPED.test(where))
|
|
34
|
-
continue;
|
|
35
|
-
let text;
|
|
36
|
-
try {
|
|
37
|
-
text = readFileSync(path, 'utf8');
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
filesRead += 1;
|
|
43
|
-
tools.push(...toolsIn(where, text));
|
|
44
|
-
injections.push(...promptFlows(where, text));
|
|
45
|
-
guards.push(...guardsIn(where, text));
|
|
46
|
-
if (!emptyBudget) {
|
|
47
|
-
const empty = budgetSetToNothing(text);
|
|
48
|
-
if (empty)
|
|
49
|
-
emptyBudget = { file: where, text: empty };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return { tools, injections, guards, filesRead, emptyBudget, config: scanConfig(root) };
|
|
53
|
-
}
|
|
54
11
|
const REACH_READS = {
|
|
55
12
|
destructive: 'cannot be undone',
|
|
56
13
|
deletes: 'deletes something',
|
|
@@ -62,7 +19,7 @@ const REACH_READS = {
|
|
|
62
19
|
'not-sure': '**not decided**',
|
|
63
20
|
};
|
|
64
21
|
export function renderAgent(scan, root) {
|
|
65
|
-
const { tools, injections, guards, filesRead, emptyBudget, config } = scan;
|
|
22
|
+
const { tools, injections, guards, filesRead, emptyBudget, config, coverage, evals } = scan;
|
|
66
23
|
const lines = ['# What this agent is allowed to do', ''];
|
|
67
24
|
if (tools.length === 0) {
|
|
68
25
|
return [
|
|
@@ -146,6 +103,7 @@ export function renderAgent(scan, root) {
|
|
|
146
103
|
lines.push(`- **${capability}** (${CAPABILITY_MEANS[capability]}): ${legTools.length === 0 ? 'nothing found' : legTools.slice(0, 6).map((t) => `\`${t.name}\``).join(', ') + (legTools.length > 6 ? `, and ${legTools.length - 6} more` : '')}`);
|
|
147
104
|
}
|
|
148
105
|
lines.push('');
|
|
106
|
+
lines.push(...coverageSection(coverage, tools, evals));
|
|
149
107
|
lines.push(...configSection(config));
|
|
150
108
|
lines.push('## What stands between them and the real world', '');
|
|
151
109
|
for (const kind of ['asks-a-person', 'dry-run', 'step-budget', 'tool-allow-list']) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Reading an agent project once: its tools, its flows, its guards, its tests and its permissions.
|
|
2
|
+
//
|
|
3
|
+
// Split from agent-command.ts, which is the file in this project that keeps arriving at the
|
|
4
|
+
// 300-line limit. The rendering belongs with the thing it renders; the walk belongs here.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join, relative } from 'node:path';
|
|
7
|
+
import { scanSourceFiles } from '../walk.js';
|
|
8
|
+
import { toolsIn } from './tools.js';
|
|
9
|
+
import { promptFlows } from './prompt-flow.js';
|
|
10
|
+
import { budgetSetToNothing, guardsIn } from './guards.js';
|
|
11
|
+
import { scanConfig } from './claude-config.js';
|
|
12
|
+
import { coverageOf, evalEvidence } from './tool-tests.js';
|
|
13
|
+
const NL = String.fromCharCode(10);
|
|
14
|
+
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
15
|
+
/**
|
|
16
|
+
* What is not the shipped agent.
|
|
17
|
+
*
|
|
18
|
+
* The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
|
|
19
|
+
* documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
|
|
20
|
+
* all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
|
|
21
|
+
* whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
|
|
22
|
+
* at the front of the file. A list of an agent's tools that is actually a list of test doubles is
|
|
23
|
+
* worse than no list.
|
|
24
|
+
*
|
|
25
|
+
* `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
|
|
26
|
+
*/
|
|
27
|
+
const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|examples?|samples?|docs?|fixtures?|node_modules|\.venv|venv)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|(?:^|[\\/])test_[^\\/]*\.py$/i;
|
|
28
|
+
export function scanAgent(root) {
|
|
29
|
+
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
30
|
+
const tools = [];
|
|
31
|
+
const tests = [];
|
|
32
|
+
const injections = [];
|
|
33
|
+
const guards = [];
|
|
34
|
+
let emptyBudget = null;
|
|
35
|
+
let filesRead = 0;
|
|
36
|
+
for (const path of scan.files) {
|
|
37
|
+
const where = relative(root, path).replace(/\\/g, '/');
|
|
38
|
+
// Excluded from the tool scan, but not thrown away: these files are the evidence for whether
|
|
39
|
+
// anybody ever wrote a test about a tool, which is a different question from what ships.
|
|
40
|
+
if (NOT_SHIPPED.test(where)) {
|
|
41
|
+
try {
|
|
42
|
+
tests.push({ file: where, text: readFileSync(path, 'utf8') });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A file that cannot be read is not evidence of a test, and is not counted as one.
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = readFileSync(path, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
filesRead += 1;
|
|
57
|
+
tools.push(...toolsIn(where, text));
|
|
58
|
+
injections.push(...promptFlows(where, text));
|
|
59
|
+
guards.push(...guardsIn(where, text));
|
|
60
|
+
if (!emptyBudget) {
|
|
61
|
+
const empty = budgetSetToNothing(text);
|
|
62
|
+
if (empty)
|
|
63
|
+
emptyBudget = { file: where, text: empty };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const manifests = ['package.json', 'pyproject.toml', 'requirements.txt'].flatMap((m) => {
|
|
67
|
+
try {
|
|
68
|
+
return [readFileSync(join(root, m), 'utf8')];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
tools,
|
|
76
|
+
injections,
|
|
77
|
+
guards,
|
|
78
|
+
filesRead,
|
|
79
|
+
emptyBudget,
|
|
80
|
+
coverage: coverageOf(tools.map((t) => t.name), tests),
|
|
81
|
+
evals: evalEvidence(tests, manifests),
|
|
82
|
+
config: scanConfig(root),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval frameworks, by the name they are imported or depended on under.
|
|
3
|
+
*
|
|
4
|
+
* Named rather than guessed at. The point of listing them is to be able to say "this project has
|
|
5
|
+
* one, here it is" or "none of these is here" - and the second sentence is only honest if the list
|
|
6
|
+
* is written down where a reader can see what was looked for.
|
|
7
|
+
*/
|
|
8
|
+
const FRAMEWORKS = [
|
|
9
|
+
{ name: 'DeepEval', re: /\bdeepeval\b/i },
|
|
10
|
+
{ name: 'promptfoo', re: /\bpromptfoo\b/i },
|
|
11
|
+
{ name: 'Ragas', re: /\bragas\b/i },
|
|
12
|
+
{ name: 'LangSmith', re: /\blangsmith\b/i },
|
|
13
|
+
{ name: 'Braintrust', re: /\bbraintrust\b/i },
|
|
14
|
+
{ name: 'Inspect AI', re: /\binspect_ai\b/i },
|
|
15
|
+
{ name: 'Giskard', re: /\bgiskard\b/i },
|
|
16
|
+
{ name: 'TruLens', re: /\btrulens\b/i },
|
|
17
|
+
{ name: 'Langfuse', re: /\blangfuse\b/i },
|
|
18
|
+
{ name: 'OpenAI Evals', re: /\bopenai[-_]?evals\b/i },
|
|
19
|
+
];
|
|
20
|
+
/** A path that holds an eval set rather than a unit test. */
|
|
21
|
+
const EVAL_PATH = /(?:^|[\\/])(?:evals?|benchmarks?|golden|datasets?)[\\/]|\.eval\.[cm]?[jt]s$|_eval\.py$|eval_[^\\/]*\.(?:py|[cm]?[jt]s)$|\bevals?\.ya?ml$/i;
|
|
22
|
+
/**
|
|
23
|
+
* Names too common to decide on.
|
|
24
|
+
*
|
|
25
|
+
* A tool called `get` or `run` appears in every file in the project, so finding it in a test file
|
|
26
|
+
* says nothing at all. Rather than counting those as covered - which is the comfortable direction
|
|
27
|
+
* and the wrong one - they are reported as undecidable, by name.
|
|
28
|
+
*/
|
|
29
|
+
const COMMON = new Set(['get', 'set', 'run', 'add', 'list', 'read', 'write', 'call', 'send', 'find', 'query', 'search', 'name', 'tool', 'test', 'echo', 'exec', 'open', 'close', 'load', 'save']);
|
|
30
|
+
const tooCommonName = (name) => name.length < 5 || COMMON.has(name.toLowerCase());
|
|
31
|
+
export function coverageOf(toolNames, tests) {
|
|
32
|
+
return toolNames.map((name) => {
|
|
33
|
+
if (tooCommonName(name))
|
|
34
|
+
return { name, mentionedIn: [], tooCommon: true };
|
|
35
|
+
// Word boundaries on both sides: `delete_user` must not be matched by `delete_user_profile`,
|
|
36
|
+
// which is a different tool and may well be the only one anybody tested.
|
|
37
|
+
const mention = new RegExp(String.raw `(?<![\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\w-])`);
|
|
38
|
+
return { name, mentionedIn: tests.filter((t) => mention.test(t.text)).map((t) => t.file), tooCommon: false };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function evalEvidence(files, manifests) {
|
|
42
|
+
const frameworks = new Set();
|
|
43
|
+
for (const { name, re } of FRAMEWORKS) {
|
|
44
|
+
if (manifests.some((m) => re.test(m)) || files.some((f) => re.test(f.text)))
|
|
45
|
+
frameworks.add(name);
|
|
46
|
+
}
|
|
47
|
+
const evalFiles = files.filter((f) => EVAL_PATH.test(f.file)).map((f) => f.file);
|
|
48
|
+
return { frameworks: [...frameworks], files: [...new Set(evalFiles)].slice(0, 12) };
|
|
49
|
+
}
|
|
50
|
+
/** The names of every eval framework looked for, so silence about them can be read correctly. */
|
|
51
|
+
export const FRAMEWORKS_LOOKED_FOR = FRAMEWORKS.map((f) => f.name);
|
|
52
|
+
/**
|
|
53
|
+
* Which tools anybody wrote a test about, rendered where the logic for deciding it lives.
|
|
54
|
+
*
|
|
55
|
+
* Split out for the same reason the config section was: agent-command.ts is the file that keeps
|
|
56
|
+
* reaching the 300-line limit, and a report section belongs beside the thing it reports on.
|
|
57
|
+
*/
|
|
58
|
+
export function coverageSection(coverage, tools, evals) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
const IRREV = ['destructive', 'deletes', 'pays', 'sends', 'runs-code'];
|
|
61
|
+
const untested = coverage.filter((c) => !c.tooCommon && c.mentionedIn.length === 0);
|
|
62
|
+
const riskyUntested = untested.filter((u) => IRREV.includes(tools.find((t) => t.name === u.name)?.reach ?? 'reads'));
|
|
63
|
+
lines.push('## Which of these anybody wrote a test about', '');
|
|
64
|
+
lines.push(untested.length === 0
|
|
65
|
+
? 'Every tool whose name is distinctive enough to look for appears in at least one test file.'
|
|
66
|
+
: `**${untested.length} of ${tools.length}** appear in no test file${riskyUntested.length > 0 ? `, and ${riskyUntested.length} of those cannot be undone` : ''}.`, '');
|
|
67
|
+
if (untested.length > 0) {
|
|
68
|
+
for (const u of untested.slice(0, 15)) {
|
|
69
|
+
const reach = tools.find((t) => t.name === u.name)?.reach;
|
|
70
|
+
lines.push(`- \`${u.name}\`${reach && IRREV.includes(reach) ? ' — **and it cannot be undone**' : ''}`);
|
|
71
|
+
}
|
|
72
|
+
if (untested.length > 15)
|
|
73
|
+
lines.push(`…and ${untested.length - 15} more`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
}
|
|
76
|
+
const undecidable = coverage.filter((c) => c.tooCommon);
|
|
77
|
+
if (undecidable.length > 0) {
|
|
78
|
+
lines.push(`Not decided for ${undecidable.length}: ${undecidable.slice(0, 8).map((u) => `\`${u.name}\``).join(', ')}. A name this`, 'short or this common appears everywhere, so finding it in a test file would prove nothing.', '');
|
|
79
|
+
}
|
|
80
|
+
lines.push(evals.frameworks.length > 0
|
|
81
|
+
? `Eval framework in this project: ${evals.frameworks.join(', ')}.`
|
|
82
|
+
: `No eval framework found. Looked for: ${FRAMEWORKS_LOOKED_FOR.join(', ')}.`, '');
|
|
83
|
+
if (evals.files.length > 0)
|
|
84
|
+
lines.push(`Eval sets: ${evals.files.map((f) => `\`${f}\``).join(', ')}.`, '');
|
|
85
|
+
lines.push('A name appearing in a test file means somebody wrote it down there. Whether that test exercises', 'the tool, mocks it, or only imports it is not decided here.', '', 'And the other way round, which matters more: a tool missing from this list is not a tool whose', 'behaviour is untested. It is a tool nothing tests **by that name** - the wiring between the name', 'the model sees and the code behind it. Tewip finds 8 of its own 17 this way, and the engine under', 'each one is covered; what nothing exercises is the tool surface itself.', '');
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|