@bytesbrains/pi-tool-awareness-gate 1.0.3
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/AGENTS.md +36 -0
- package/README.md +176 -0
- package/package.json +51 -0
- package/src/config.ts +69 -0
- package/src/format.ts +201 -0
- package/src/helpers.ts +124 -0
- package/src/index.ts +184 -0
- package/src/infer.ts +361 -0
- package/src/types.ts +138 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Tool Awareness Gate — Agent Instructions
|
|
2
|
+
|
|
3
|
+
This gate operates transparently. No special agent instructions needed — it injects limitation signals directly into tool result context.
|
|
4
|
+
|
|
5
|
+
## How Agents Should Use Awareness Signals
|
|
6
|
+
|
|
7
|
+
When you see a tool result prefixed with a limitation reminder:
|
|
8
|
+
|
|
9
|
+
⚠️ [bash] conf:0% partial truncated
|
|
10
|
+
• Non-zero exit code: 1 • Output was truncated
|
|
11
|
+
→ Check stderr output for error details before retrying
|
|
12
|
+
|
|
13
|
+
**Do:**
|
|
14
|
+
- Read the status icon (✅ success, ⚠️ partial, ❌ failure, ⏱️ timeout, 🔒 unauthorized)
|
|
15
|
+
- Check `conf:N%` — confidence below 50% means the result may be unreliable
|
|
16
|
+
- Read warnings — they explain what went wrong
|
|
17
|
+
- Follow suggestions — they provide corrective actions
|
|
18
|
+
- For `partial` results: consider retrying or narrowing scope
|
|
19
|
+
- For `failure` results: do not treat the output as valid
|
|
20
|
+
|
|
21
|
+
**Don't:**
|
|
22
|
+
- Ignore the reminder and treat the result as authoritative
|
|
23
|
+
- Proceed with downstream actions based on partial/failed results without verification
|
|
24
|
+
- Ignore truncation warnings — you may be missing critical data
|
|
25
|
+
|
|
26
|
+
## Rich Payload Access
|
|
27
|
+
|
|
28
|
+
The full envelope is available in `details._awareness` for programmatic inspection. Agents that need to make meta-decisions (retry? escalate? ask user?) should inspect:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
details._awareness.status // "success" | "partial" | "failure" | "timeout" | "unauthorized"
|
|
32
|
+
details._awareness.quality // { confidence, completeness, freshness, accuracy }
|
|
33
|
+
details._awareness.limitations // { truncated, stale, limited_scope, requires_human_review, ... }
|
|
34
|
+
details._awareness.warnings // string[]
|
|
35
|
+
details._awareness.suggestions // string[] (optional)
|
|
36
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Tool Awareness Gate for Pi
|
|
2
|
+
|
|
3
|
+
> Intercepts every tool result, infers quality signals, injects limitation reminders into agent context, and logs structured envelopes for evaluation. **Zero code changes required in other gates.**
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-tool-awareness-gate
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add to `.pi/settings.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"packages": ["npm:pi-tool-awareness-gate"]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What It Does
|
|
20
|
+
|
|
21
|
+
Every tool your agent calls — bash, read, write, edit, browser, CI, doctor — returns a result. But how reliable is that result? The awareness gate intercepts every `tool_result` event and adds:
|
|
22
|
+
|
|
23
|
+
| Signal | What It Tells the Agent |
|
|
24
|
+
|---|---|
|
|
25
|
+
| **Status** | `success`, `partial`, `failure`, `timeout`, `unauthorized` |
|
|
26
|
+
| **Confidence** | 0-1 score — how reliable is this result? |
|
|
27
|
+
| **Completeness** | `full`, `partial`, `minimal` — did we get everything? |
|
|
28
|
+
| **Truncation** | Was output cut off? |
|
|
29
|
+
| **Freshness** | How stale is the data? |
|
|
30
|
+
| **Warnings** | Human + agent readable caveats |
|
|
31
|
+
| **Suggestions** | Actionable next steps ("retry with narrower scope") |
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
Tool executes → tool_result event fires
|
|
37
|
+
│
|
|
38
|
+
▼
|
|
39
|
+
┌─────────────────────────────┐
|
|
40
|
+
│ 1. Infer quality signals │ ← heuristic analysis of raw output
|
|
41
|
+
│ 2. Detect limitation flags │ ← truncated? stale? scoped?
|
|
42
|
+
│ 3. Generate warnings │ ← "Output was truncated"
|
|
43
|
+
│ 4. Format short reminder │ ← 1-2 line summary
|
|
44
|
+
│ 5. Inject into agent context│ ← prepended to result text
|
|
45
|
+
│ 6. Log rich payload │ ← .awareness/envelopes.log
|
|
46
|
+
└─────────────────────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Short Reminder Example
|
|
50
|
+
|
|
51
|
+
When a tool result has issues, the agent sees:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
⚠️ [bash] conf:0% partial truncated
|
|
55
|
+
• Non-zero exit code: 1 • Output was truncated
|
|
56
|
+
→ Check stderr output for error details before retrying
|
|
57
|
+
|
|
58
|
+
$ ls /nonexistent
|
|
59
|
+
ls: /nonexistent: No such file or directory
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Rich Payload Example
|
|
63
|
+
|
|
64
|
+
Logged to `.awareness/envelopes.log`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"timestamp": "2026-05-15T10:30:00.000Z",
|
|
69
|
+
"tool": "bash",
|
|
70
|
+
"invocation_id": "bash-42",
|
|
71
|
+
"status": "partial",
|
|
72
|
+
"latency_ms": 45,
|
|
73
|
+
"quality": {
|
|
74
|
+
"confidence": 0.8,
|
|
75
|
+
"completeness": "full",
|
|
76
|
+
"freshness": 0.5,
|
|
77
|
+
"accuracy": 0.8
|
|
78
|
+
},
|
|
79
|
+
"limitations": { "truncated": false },
|
|
80
|
+
"warnings": [],
|
|
81
|
+
"suggestions": []
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Tool-Specific Inference
|
|
86
|
+
|
|
87
|
+
The gate has tool-specific heuristics for richer signals:
|
|
88
|
+
|
|
89
|
+
| Tool | Extra Signals Detected |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `bash` | Exit code, stderr presence, timeout |
|
|
92
|
+
| `read` | Truncation at limit, offset/limit awareness |
|
|
93
|
+
| `write`/`edit` | Bytes written vs expected |
|
|
94
|
+
| `browser_*` | HTTP errors, OCR failures, timeouts |
|
|
95
|
+
| `ci_*` | API errors, Gitea connectivity issues |
|
|
96
|
+
| `doctor_*` | Informational — no warnings |
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
Create `.awarenessrc.yml` in your project root (optional — sensible defaults):
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
# Enable/disable the awareness layer
|
|
104
|
+
enabled: true
|
|
105
|
+
|
|
106
|
+
# Inject short reminders into agent context
|
|
107
|
+
injectReminders: true
|
|
108
|
+
|
|
109
|
+
# Log rich payloads for evaluation
|
|
110
|
+
logEnvelopes: true
|
|
111
|
+
|
|
112
|
+
# Where to write envelope logs
|
|
113
|
+
envelopeLogPath: .awareness/envelopes.log
|
|
114
|
+
|
|
115
|
+
# Max warnings to include per short reminder (before truncation)
|
|
116
|
+
maxWarningsInReminder: 3
|
|
117
|
+
|
|
118
|
+
# Quality thresholds
|
|
119
|
+
thresholds.lowConfidence: 0.5
|
|
120
|
+
thresholds.staleFreshness: 0.3
|
|
121
|
+
thresholds.maxWarningsBeforeCritical: 5
|
|
122
|
+
|
|
123
|
+
# Tools to skip awareness tracking (comma-separated)
|
|
124
|
+
excludedTools:
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Architecture
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
tool-awareness-gate/
|
|
131
|
+
├── package.json
|
|
132
|
+
├── src/
|
|
133
|
+
│ ├── index.ts ← Hooks pi.on("tool_result"), main orchestration
|
|
134
|
+
│ ├── types.ts ← ToolResultEnvelope<T>, QualitySignals, LimitationFlags
|
|
135
|
+
│ ├── infer.ts ← Heuristic inference from raw tool outputs
|
|
136
|
+
│ ├── format.ts ← Short reminder + rich payload formatters
|
|
137
|
+
│ ├── config.ts ← .awarenessrc.yml loader
|
|
138
|
+
│ └── helpers.ts ← Invocation IDs, text extraction, log appending
|
|
139
|
+
└── README.md
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Integration with Other Gates
|
|
143
|
+
|
|
144
|
+
**No code changes needed.** The awareness gate works at the framework level, intercepting events from all gates automatically.
|
|
145
|
+
|
|
146
|
+
When a gate wants to provide domain-specific quality signals (richer than what inference can detect), it imports the envelope types:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import type { ToolResultEnvelope } from "pi-tool-awareness-gate/src/types";
|
|
150
|
+
|
|
151
|
+
// Gate enriches its own result with domain-specific quality signals
|
|
152
|
+
return {
|
|
153
|
+
content: [...],
|
|
154
|
+
details: {
|
|
155
|
+
...details,
|
|
156
|
+
_awareness: {
|
|
157
|
+
quality: { freshness: 0.95 }, // CI just fetched live data
|
|
158
|
+
limitations: { truncated: false },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The awareness gate will **merge** gate-provided signals with its own inference, preferring the gate's signals when available.
|
|
165
|
+
|
|
166
|
+
## Benefits
|
|
167
|
+
|
|
168
|
+
- **More trustworthy agents** — agents know when to doubt results
|
|
169
|
+
- **Better debugging** — structured logs show tool quality over time
|
|
170
|
+
- **Reduced overconfidence** — agents see explicit confidence scores
|
|
171
|
+
- **Progressive adoption** — works automatically, gates enrich when ready
|
|
172
|
+
- **Zero breaking changes** — no modifications to existing tool code
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT © [nandal](https://github.com/nandal)
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bytesbrains/pi-tool-awareness-gate",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Tool limitation awareness layer for AI agents — intercepts tool results, infers quality signals, injects limitation reminders, and logs structured envelopes for evaluation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"tool-awareness",
|
|
9
|
+
"quality-signals",
|
|
10
|
+
"limitation-detection",
|
|
11
|
+
"agent-protocol",
|
|
12
|
+
"observability"
|
|
13
|
+
],
|
|
14
|
+
"author": "nandal <nandal@users.noreply.github.com>",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/nandal/pi-ext",
|
|
18
|
+
"directory": "tool-awareness-gate"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/nandal/pi-ext/tree/main/tool-awareness-gate",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/nandal/pi-ext/issues"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"main": "./src/index.ts",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"AGENTS.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
37
|
+
"typebox": "*"
|
|
38
|
+
},
|
|
39
|
+
"pi": {
|
|
40
|
+
"extensions": [
|
|
41
|
+
"./src/index.ts"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"vitest": "^2.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-awareness-gate — Config
|
|
3
|
+
*
|
|
4
|
+
* Loads .awarenessrc.yml from project root.
|
|
5
|
+
* Falls back to sensible defaults if not present.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { AwarenessConfig } from "./types";
|
|
10
|
+
import { DEFAULT_AWARENESS_CONFIG } from "./types";
|
|
11
|
+
|
|
12
|
+
export function loadConfig(cwd: string): AwarenessConfig {
|
|
13
|
+
const configPath = path.join(cwd, ".awarenessrc.yml");
|
|
14
|
+
const config: AwarenessConfig = { ...DEFAULT_AWARENESS_CONFIG };
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(configPath)) return config;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
20
|
+
const result: Record<string, string> = {};
|
|
21
|
+
|
|
22
|
+
for (const line of content.split("\n")) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
25
|
+
const m = trimmed.match(/^\s*([\w][\w.]*):\s*(.+)$/);
|
|
26
|
+
if (m) {
|
|
27
|
+
let v = m[2].trim();
|
|
28
|
+
// Strip surrounding quotes
|
|
29
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
30
|
+
v = v.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
result[m[1]] = v;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Top-level booleans
|
|
37
|
+
if ("enabled" in result) config.enabled = result["enabled"] !== "false";
|
|
38
|
+
if ("injectReminders" in result) config.injectReminders = result["injectReminders"] !== "false";
|
|
39
|
+
if ("logEnvelopes" in result) config.logEnvelopes = result["logEnvelopes"] !== "false";
|
|
40
|
+
|
|
41
|
+
// Strings
|
|
42
|
+
if (result["envelopeLogPath"]) config.envelopeLogPath = result["envelopeLogPath"];
|
|
43
|
+
if (result["excludedTools"]) {
|
|
44
|
+
config.excludedTools = result["excludedTools"].split(",").map(s => s.trim()).filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Numbers
|
|
48
|
+
if (result["maxWarningsInReminder"]) {
|
|
49
|
+
const n = parseInt(result["maxWarningsInReminder"], 10);
|
|
50
|
+
if (!isNaN(n)) config.maxWarningsInReminder = n;
|
|
51
|
+
}
|
|
52
|
+
if (result["thresholds.lowConfidence"]) {
|
|
53
|
+
const n = parseFloat(result["thresholds.lowConfidence"]);
|
|
54
|
+
if (!isNaN(n)) config.thresholds.lowConfidence = n;
|
|
55
|
+
}
|
|
56
|
+
if (result["thresholds.staleFreshness"]) {
|
|
57
|
+
const n = parseFloat(result["thresholds.staleFreshness"]);
|
|
58
|
+
if (!isNaN(n)) config.thresholds.staleFreshness = n;
|
|
59
|
+
}
|
|
60
|
+
if (result["thresholds.maxWarningsBeforeCritical"]) {
|
|
61
|
+
const n = parseInt(result["thresholds.maxWarningsBeforeCritical"], 10);
|
|
62
|
+
if (!isNaN(n)) config.thresholds.maxWarningsBeforeCritical = n;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return config;
|
|
66
|
+
} catch {
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-awareness-gate — Formatters
|
|
3
|
+
*
|
|
4
|
+
* Formats ToolResultEnvelope into:
|
|
5
|
+
* 1. Short reminders — 1-2 line summaries injected into agent context
|
|
6
|
+
* 2. Rich payloads — structured JSON for logging and evaluation
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
ToolResultEnvelope,
|
|
10
|
+
ShortReminder,
|
|
11
|
+
AwarenessConfig,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
// ── Icons ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const STATUS_ICONS: Record<string, string> = {
|
|
17
|
+
success: "✅",
|
|
18
|
+
partial: "⚠️",
|
|
19
|
+
failure: "❌",
|
|
20
|
+
timeout: "⏱️",
|
|
21
|
+
unauthorized: "🔒",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const WARNING_PREFIX: Record<string, string> = {
|
|
25
|
+
info: "ℹ️",
|
|
26
|
+
warn: "⚠️",
|
|
27
|
+
critical: "🚨",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── Short Reminder ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format a short reminder (1-2 lines) for injection into agent context.
|
|
34
|
+
*/
|
|
35
|
+
export function formatShortReminder(
|
|
36
|
+
envelope: ToolResultEnvelope,
|
|
37
|
+
config: AwarenessConfig,
|
|
38
|
+
): ShortReminder {
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
const icon = STATUS_ICONS[envelope.status] || "❓";
|
|
41
|
+
const truncatedWarnings = envelope.warnings.slice(0, config.maxWarningsInReminder);
|
|
42
|
+
|
|
43
|
+
// Build a compact one-liner starting with tool name
|
|
44
|
+
let summary = `[${envelope.meta.tool_name}] ${icon}`;
|
|
45
|
+
|
|
46
|
+
// Add key quality signals as compact badges
|
|
47
|
+
if (envelope.quality.confidence !== undefined) {
|
|
48
|
+
const pct = Math.round(envelope.quality.confidence * 100);
|
|
49
|
+
summary += ` conf:${pct}%`;
|
|
50
|
+
}
|
|
51
|
+
if (envelope.quality.completeness && envelope.quality.completeness !== "full") {
|
|
52
|
+
summary += ` ${envelope.quality.completeness}`;
|
|
53
|
+
}
|
|
54
|
+
if (envelope.limitations.truncated) {
|
|
55
|
+
summary += " truncated";
|
|
56
|
+
}
|
|
57
|
+
if (envelope.limitations.stale) {
|
|
58
|
+
summary += " stale";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parts.push(summary);
|
|
62
|
+
|
|
63
|
+
// Add warnings as a second line if any
|
|
64
|
+
if (truncatedWarnings.length > 0) {
|
|
65
|
+
const warnText = truncatedWarnings
|
|
66
|
+
.map(w => `• ${w}`)
|
|
67
|
+
.join(" | ");
|
|
68
|
+
parts.push(` ${warnText}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (envelope.warnings.length > config.maxWarningsInReminder) {
|
|
72
|
+
const remaining = envelope.warnings.length - config.maxWarningsInReminder;
|
|
73
|
+
parts.push(` ... +${remaining} more warnings`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Add top suggestion if available
|
|
77
|
+
if (envelope.suggestions && envelope.suggestions.length > 0) {
|
|
78
|
+
parts.push(` → ${envelope.suggestions[0]}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Determine severity level
|
|
82
|
+
let level: ShortReminder["level"] = "info";
|
|
83
|
+
if (envelope.status === "failure" || envelope.status === "timeout") {
|
|
84
|
+
level = "critical";
|
|
85
|
+
} else if (
|
|
86
|
+
envelope.warnings.length >= config.thresholds.maxWarningsBeforeCritical
|
|
87
|
+
) {
|
|
88
|
+
level = "critical";
|
|
89
|
+
} else if (
|
|
90
|
+
envelope.warnings.length > 0 ||
|
|
91
|
+
envelope.status === "partial"
|
|
92
|
+
) {
|
|
93
|
+
level = "warn";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
tool: envelope.meta.tool_name,
|
|
98
|
+
summary: `${WARNING_PREFIX[level]} ${parts.join("\n")}`,
|
|
99
|
+
level,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inject a short reminder into a tool result's content.
|
|
105
|
+
* Prepends the reminder to the first text block.
|
|
106
|
+
*/
|
|
107
|
+
export function injectReminder(
|
|
108
|
+
result: unknown,
|
|
109
|
+
reminder: ShortReminder,
|
|
110
|
+
): unknown {
|
|
111
|
+
if (!result || typeof result !== "object") return result;
|
|
112
|
+
|
|
113
|
+
const r = result as Record<string, unknown>;
|
|
114
|
+
|
|
115
|
+
// Only inject if result has a content array (standard pi tool result)
|
|
116
|
+
if (!Array.isArray(r.content) || r.content.length === 0) return result;
|
|
117
|
+
|
|
118
|
+
const blocks = r.content as Array<Record<string, unknown>>;
|
|
119
|
+
const firstTextIdx = blocks.findIndex(
|
|
120
|
+
b => b.type === "text" && typeof b.text === "string",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (firstTextIdx === -1) return result;
|
|
124
|
+
|
|
125
|
+
const original = blocks[firstTextIdx].text as string;
|
|
126
|
+
blocks[firstTextIdx] = {
|
|
127
|
+
type: "text",
|
|
128
|
+
text: `${reminder.summary}\n\n${original}`,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return { ...r, content: blocks };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Rich Payload ──────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Format the full ToolResultEnvelope as a loggable JSON object.
|
|
138
|
+
*/
|
|
139
|
+
export function formatEnvelopeLog(
|
|
140
|
+
envelope: ToolResultEnvelope,
|
|
141
|
+
): Record<string, unknown> {
|
|
142
|
+
return {
|
|
143
|
+
timestamp: envelope.meta.timestamp,
|
|
144
|
+
tool: envelope.meta.tool_name,
|
|
145
|
+
invocation_id: envelope.meta.invocation_id,
|
|
146
|
+
status: envelope.status,
|
|
147
|
+
latency_ms: envelope.meta.latency_ms,
|
|
148
|
+
quality: envelope.quality,
|
|
149
|
+
limitations: envelope.limitations,
|
|
150
|
+
warnings: envelope.warnings,
|
|
151
|
+
suggestions: envelope.suggestions || [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Display Formatters ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format envelope as a human-readable text block (for supervisor_status-like tools).
|
|
159
|
+
*/
|
|
160
|
+
export function formatEnvelopeDisplay(envelope: ToolResultEnvelope): string {
|
|
161
|
+
const icon = STATUS_ICONS[envelope.status] || "❓";
|
|
162
|
+
const lines = [
|
|
163
|
+
`${icon} Tool: ${envelope.meta.tool_name}`,
|
|
164
|
+
` Status: ${envelope.status} | Latency: ${envelope.meta.latency_ms}ms | Retries: ${envelope.meta.retries}`,
|
|
165
|
+
` Invocation: ${envelope.meta.invocation_id}`,
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
if (Object.keys(envelope.quality).length > 0) {
|
|
169
|
+
const q = envelope.quality;
|
|
170
|
+
const qParts: string[] = [];
|
|
171
|
+
if (q.confidence !== undefined) qParts.push(`confidence: ${(q.confidence * 100).toFixed(0)}%`);
|
|
172
|
+
if (q.completeness) qParts.push(`completeness: ${q.completeness}`);
|
|
173
|
+
if (q.freshness !== undefined) qParts.push(`freshness: ${(q.freshness * 100).toFixed(0)}%`);
|
|
174
|
+
lines.push(` Quality: ${qParts.join(" | ")}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (Object.keys(envelope.limitations).length > 0) {
|
|
178
|
+
const activeFlags = Object.entries(envelope.limitations)
|
|
179
|
+
.filter(([, v]) => v === true)
|
|
180
|
+
.map(([k]) => k);
|
|
181
|
+
if (activeFlags.length > 0) {
|
|
182
|
+
lines.push(` Limitations: ${activeFlags.join(", ")}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (envelope.warnings.length > 0) {
|
|
187
|
+
lines.push(` Warnings (${envelope.warnings.length}):`);
|
|
188
|
+
for (const w of envelope.warnings) {
|
|
189
|
+
lines.push(` • ${w}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (envelope.suggestions && envelope.suggestions.length > 0) {
|
|
194
|
+
lines.push(` Suggestions:`);
|
|
195
|
+
for (const s of envelope.suggestions) {
|
|
196
|
+
lines.push(` → ${s}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return lines.join("\n");
|
|
201
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-awareness-gate — Helpers
|
|
3
|
+
*
|
|
4
|
+
* Utility functions for the awareness gate.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import type { AwarenessConfig } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate a unique invocation ID for each tool call.
|
|
12
|
+
*/
|
|
13
|
+
export function generateInvocationId(): string {
|
|
14
|
+
const timestamp = Date.now().toString(36);
|
|
15
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
16
|
+
return `${timestamp}-${random}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the envelope log path (relative to cwd).
|
|
21
|
+
*/
|
|
22
|
+
export function getEnvelopeLogPath(cwd: string, config: AwarenessConfig): string {
|
|
23
|
+
return path.join(cwd, config.envelopeLogPath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Append a structured envelope to the log.
|
|
28
|
+
*/
|
|
29
|
+
export function appendEnvelopeLog(
|
|
30
|
+
cwd: string,
|
|
31
|
+
config: AwarenessConfig,
|
|
32
|
+
entry: Record<string, unknown>,
|
|
33
|
+
): void {
|
|
34
|
+
if (!config.logEnvelopes) return;
|
|
35
|
+
const logPath = getEnvelopeLogPath(cwd, config);
|
|
36
|
+
const dir = path.dirname(logPath);
|
|
37
|
+
if (!fs.existsSync(dir)) {
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract text content from a tool result, regardless of shape.
|
|
45
|
+
*/
|
|
46
|
+
export function extractTextContent(result: unknown): string {
|
|
47
|
+
if (!result || typeof result !== "object") return "";
|
|
48
|
+
|
|
49
|
+
const r = result as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
// Standard pi tool result: { content: [{ type: "text", text: "..." }] }
|
|
52
|
+
if (Array.isArray(r.content)) {
|
|
53
|
+
const texts: string[] = [];
|
|
54
|
+
for (const block of r.content as Array<Record<string, unknown>>) {
|
|
55
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
56
|
+
texts.push(block.text);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return texts.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Raw string result
|
|
63
|
+
if (typeof r.text === "string") return r.text;
|
|
64
|
+
if (typeof r.message === "string") return r.message;
|
|
65
|
+
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if text content appears truncated.
|
|
71
|
+
* Uses heuristics: sudden cutoffs, missing closing markers, etc.
|
|
72
|
+
*/
|
|
73
|
+
export function isTextTruncated(text: string): boolean {
|
|
74
|
+
if (!text || text.length < 100) return false;
|
|
75
|
+
|
|
76
|
+
// Truncation indicators in typical tool output
|
|
77
|
+
const truncationMarkers = [
|
|
78
|
+
/\.\.\.\s*(truncated|omitted|remaining|(?:more|d+) (?:lines|bytes))/i,
|
|
79
|
+
/\[truncated\]/i,
|
|
80
|
+
/<omitted\s*\/?>/i,
|
|
81
|
+
/\.\.\.\s*$/m, // trailing ellipsis on last line
|
|
82
|
+
/\btruncated\b.*\b(?:output|result|response)\b/i,
|
|
83
|
+
// pi read tool truncation
|
|
84
|
+
/more lines in file/i,
|
|
85
|
+
/Use offset=\d+ to continue/i,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
for (const marker of truncationMarkers) {
|
|
89
|
+
if (marker.test(text)) return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Heuristic: text ends mid-sentence with no punctuation
|
|
93
|
+
const lastChar = text.trim().slice(-1);
|
|
94
|
+
const lastLine = text.trim().split("\n").pop() || "";
|
|
95
|
+
if (
|
|
96
|
+
lastLine.length > 20 &&
|
|
97
|
+
!/[.!?;:)\]}"'>]$/.test(lastChar) &&
|
|
98
|
+
!/^[\s#*\-–—]/.test(lastLine)
|
|
99
|
+
) {
|
|
100
|
+
// Could be truncated — moderate confidence
|
|
101
|
+
// Only flag if the last line looks like a natural sentence fragment
|
|
102
|
+
if (/[a-z]$/.test(lastLine) && lastLine.split(" ").length > 3) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Determine status from raw tool result.
|
|
112
|
+
*/
|
|
113
|
+
export function inferStatus(result: unknown, error?: unknown): string {
|
|
114
|
+
if (error) return "failure";
|
|
115
|
+
|
|
116
|
+
if (result && typeof result === "object") {
|
|
117
|
+
const r = result as Record<string, unknown>;
|
|
118
|
+
if (r.isError === true) return "failure";
|
|
119
|
+
if (r.status && typeof r.status === "string") return r.status;
|
|
120
|
+
if (r.timeout === true) return "timeout";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return "success";
|
|
124
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-awareness-gate — Main Extension
|
|
3
|
+
*
|
|
4
|
+
* Intercepts every tool_result event, infers quality signals and limitation
|
|
5
|
+
* flags, injects short reminders into agent context, and logs structured
|
|
6
|
+
* envelopes for evaluation.
|
|
7
|
+
*
|
|
8
|
+
* Works automatically with ALL existing gates — no code changes required.
|
|
9
|
+
*
|
|
10
|
+
* Events hooked:
|
|
11
|
+
* - tool_result → infer quality, inject reminders, log envelopes
|
|
12
|
+
* - tool_error → flag as failure
|
|
13
|
+
* - session_start → initialize config + session state
|
|
14
|
+
* - session_shutdown → flush logs
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { loadConfig } from "./config";
|
|
18
|
+
import { inferQuality, inferLimitations, inferWarnings, inferSuggestions } from "./infer";
|
|
19
|
+
import {
|
|
20
|
+
formatShortReminder,
|
|
21
|
+
formatEnvelopeLog,
|
|
22
|
+
injectReminder,
|
|
23
|
+
} from "./format";
|
|
24
|
+
import {
|
|
25
|
+
generateInvocationId,
|
|
26
|
+
appendEnvelopeLog,
|
|
27
|
+
} from "./helpers";
|
|
28
|
+
import type {
|
|
29
|
+
ToolResultEnvelope,
|
|
30
|
+
RawToolResult,
|
|
31
|
+
AwarenessConfig,
|
|
32
|
+
} from "./types";
|
|
33
|
+
|
|
34
|
+
// ── Module-level state ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
let config: AwarenessConfig | null = null;
|
|
37
|
+
/** Track invocation start times to calculate latency */
|
|
38
|
+
const invocationStarts = new Map<string, number>();
|
|
39
|
+
let invocationCounter = 0;
|
|
40
|
+
|
|
41
|
+
// ── Extension ────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export default function (pi: ExtensionAPI) {
|
|
44
|
+
// ── Session lifecycle ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
pi.on("session_start", (_event, ctx) => {
|
|
47
|
+
config = loadConfig(ctx.cwd);
|
|
48
|
+
invocationCounter = 0;
|
|
49
|
+
invocationStarts.clear();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("session_shutdown", () => {
|
|
53
|
+
invocationStarts.clear();
|
|
54
|
+
config = null;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Track tool execution start for latency calculation ───────────────────
|
|
58
|
+
|
|
59
|
+
pi.on("tool_execution_start", (event) => {
|
|
60
|
+
if (!config?.enabled) return;
|
|
61
|
+
if (config.excludedTools.includes(event.toolName)) return;
|
|
62
|
+
|
|
63
|
+
invocationCounter++;
|
|
64
|
+
const invId = `${event.toolName}-${invocationCounter}`;
|
|
65
|
+
invocationStarts.set(event.toolCallId, Date.now());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── Main interception: tool_result ───────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
71
|
+
if (!config?.enabled) return;
|
|
72
|
+
if (config.excludedTools.includes(event.toolName)) return;
|
|
73
|
+
|
|
74
|
+
const startTime = invocationStarts.get(event.toolCallId);
|
|
75
|
+
const latencyMs = startTime ? Date.now() - startTime : 0;
|
|
76
|
+
invocationStarts.delete(event.toolCallId);
|
|
77
|
+
|
|
78
|
+
// Build raw input for inference
|
|
79
|
+
const raw: RawToolResult = {
|
|
80
|
+
toolName: event.toolName,
|
|
81
|
+
result: {
|
|
82
|
+
content: event.content,
|
|
83
|
+
details: event.details,
|
|
84
|
+
isError: event.isError,
|
|
85
|
+
},
|
|
86
|
+
error: event.isError ? "tool error" : undefined,
|
|
87
|
+
isError: event.isError,
|
|
88
|
+
content: event.content,
|
|
89
|
+
details: event.details,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Infer quality signals
|
|
93
|
+
const quality = inferQuality(raw, config);
|
|
94
|
+
const limitations = inferLimitations(raw);
|
|
95
|
+
const warnings = inferWarnings(raw, quality, limitations, config);
|
|
96
|
+
const suggestions = inferSuggestions(raw, warnings, quality);
|
|
97
|
+
|
|
98
|
+
// Determine status
|
|
99
|
+
let status: ToolResultEnvelope["status"] = "success";
|
|
100
|
+
if (event.isError) {
|
|
101
|
+
status = "failure";
|
|
102
|
+
} else if (limitations.truncated || limitations.limited_scope ||
|
|
103
|
+
limitations.requires_human_review) {
|
|
104
|
+
status = "partial";
|
|
105
|
+
} else if (warnings.length > 0) {
|
|
106
|
+
status = "partial";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Build envelope
|
|
110
|
+
const envelope: ToolResultEnvelope = {
|
|
111
|
+
status,
|
|
112
|
+
result: { content: event.content, details: event.details },
|
|
113
|
+
meta: {
|
|
114
|
+
tool_name: event.toolName,
|
|
115
|
+
invocation_id: `${event.toolName}-${invocationCounter}`,
|
|
116
|
+
latency_ms: latencyMs,
|
|
117
|
+
retries: 0,
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
},
|
|
120
|
+
quality,
|
|
121
|
+
limitations,
|
|
122
|
+
warnings,
|
|
123
|
+
suggestions: suggestions.length > 0 ? suggestions : undefined,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Log rich payload
|
|
127
|
+
appendEnvelopeLog(ctx.cwd, config, formatEnvelopeLog(envelope));
|
|
128
|
+
|
|
129
|
+
// Inject short reminder into result content
|
|
130
|
+
if (config.injectReminders && (warnings.length > 0 || status !== "success")) {
|
|
131
|
+
const reminder = formatShortReminder(envelope, config);
|
|
132
|
+
const enriched = injectReminder(
|
|
133
|
+
{ content: event.content, details: event.details, isError: event.isError },
|
|
134
|
+
reminder,
|
|
135
|
+
) as { content?: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean };
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
content: enriched?.content ?? event.content,
|
|
139
|
+
details: { ...event.details, _awareness: envelope },
|
|
140
|
+
isError: event.isError,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Even without reminder injection, attach envelope to details
|
|
145
|
+
return {
|
|
146
|
+
details: { ...event.details, _awareness: envelope },
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Error tracking ───────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
pi.on("tool_error", (event, ctx) => {
|
|
153
|
+
if (!config?.enabled) return;
|
|
154
|
+
if (config.excludedTools.includes(event.toolName)) return;
|
|
155
|
+
|
|
156
|
+
const startTime = invocationStarts.get(event.toolCallId);
|
|
157
|
+
const latencyMs = startTime ? Date.now() - startTime : 0;
|
|
158
|
+
|
|
159
|
+
const envelope: ToolResultEnvelope = {
|
|
160
|
+
status: "failure",
|
|
161
|
+
result: { error: String(event.error) },
|
|
162
|
+
meta: {
|
|
163
|
+
tool_name: event.toolName,
|
|
164
|
+
invocation_id: `${event.toolName}-${++invocationCounter}`,
|
|
165
|
+
latency_ms: latencyMs,
|
|
166
|
+
retries: 0,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
},
|
|
169
|
+
quality: {
|
|
170
|
+
confidence: 0,
|
|
171
|
+
completeness: "minimal",
|
|
172
|
+
freshness: 0,
|
|
173
|
+
accuracy: 0,
|
|
174
|
+
},
|
|
175
|
+
limitations: {
|
|
176
|
+
requires_human_review: true,
|
|
177
|
+
},
|
|
178
|
+
warnings: [`Tool execution failed: ${String(event.error).substring(0, 200)}`],
|
|
179
|
+
suggestions: ["Check tool parameters and retry", "Consider fallback approach"],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
appendEnvelopeLog(ctx.cwd, config, formatEnvelopeLog(envelope));
|
|
183
|
+
});
|
|
184
|
+
}
|
package/src/infer.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-awareness-gate — Inference Engine
|
|
3
|
+
*
|
|
4
|
+
* Inspects raw tool results from the `tool_result` event and
|
|
5
|
+
* infers quality signals, limitation flags, and warnings.
|
|
6
|
+
*
|
|
7
|
+
* Inference is heuristic-based — domain-specific signals require
|
|
8
|
+
* gates to enrich their results using the ToolResultEnvelope types.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
RawToolResult,
|
|
12
|
+
QualitySignals,
|
|
13
|
+
LimitationFlags,
|
|
14
|
+
AwarenessConfig,
|
|
15
|
+
} from "./types";
|
|
16
|
+
import { extractTextContent, isTextTruncated, inferStatus } from "./helpers";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect common bash error patterns in command output.
|
|
20
|
+
* pi's standard bash tool doesn't expose exitCode in details,
|
|
21
|
+
* so we detect errors from content text heuristics.
|
|
22
|
+
*/
|
|
23
|
+
function looksLikeBashError(content: string): boolean {
|
|
24
|
+
if (!content || content.length < 3) return false;
|
|
25
|
+
|
|
26
|
+
const errorPatterns = [
|
|
27
|
+
/No such file or directory/i,
|
|
28
|
+
/command not found/i,
|
|
29
|
+
/Permission denied/i,
|
|
30
|
+
/cannot access/i,
|
|
31
|
+
/not a directory/i,
|
|
32
|
+
/Is a directory/i,
|
|
33
|
+
/Operation not permitted/i,
|
|
34
|
+
/No space left on device/i,
|
|
35
|
+
/Connection refused/i,
|
|
36
|
+
/Could not resolve host/i,
|
|
37
|
+
/fatal:/i,
|
|
38
|
+
/error:/i,
|
|
39
|
+
/Error:/,
|
|
40
|
+
/ERROR:/,
|
|
41
|
+
/syntax error/i,
|
|
42
|
+
/unexpected token/i,
|
|
43
|
+
/npm error/i,
|
|
44
|
+
/npm ERR!/,
|
|
45
|
+
/exit code [1-9]/i,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Check first few lines — errors typically appear early
|
|
49
|
+
const firstLines = content.split("\n").slice(0, 10).join("\n");
|
|
50
|
+
for (const pattern of errorPatterns) {
|
|
51
|
+
if (pattern.test(firstLines)) return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Infer quality signals from a raw tool result.
|
|
59
|
+
*/
|
|
60
|
+
export function inferQuality(
|
|
61
|
+
raw: RawToolResult,
|
|
62
|
+
_config: AwarenessConfig,
|
|
63
|
+
): QualitySignals {
|
|
64
|
+
const signals: QualitySignals = {};
|
|
65
|
+
const content = extractTextContent(raw.result);
|
|
66
|
+
const error = raw.error || (raw.result as any)?.isError;
|
|
67
|
+
|
|
68
|
+
// Confidence: penalized by errors, empty results, bash errors
|
|
69
|
+
if (error) {
|
|
70
|
+
signals.confidence = 0.0;
|
|
71
|
+
} else if (!content || content.length === 0) {
|
|
72
|
+
signals.confidence = 0.3;
|
|
73
|
+
} else if (raw.toolName === "bash" && looksLikeBashError(content)) {
|
|
74
|
+
signals.confidence = 0.3;
|
|
75
|
+
} else {
|
|
76
|
+
signals.confidence = 0.8; // Default for successful tool calls
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Completeness
|
|
80
|
+
if (!content || content.length === 0) {
|
|
81
|
+
signals.completeness = "minimal";
|
|
82
|
+
} else if (
|
|
83
|
+
isTextTruncated(content) ||
|
|
84
|
+
(raw.result as any)?.details?.truncated === true
|
|
85
|
+
) {
|
|
86
|
+
signals.completeness = "partial";
|
|
87
|
+
} else {
|
|
88
|
+
signals.completeness = "full";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Freshness — can't infer without timestamps in result
|
|
92
|
+
signals.freshness = 0.5; // Unknown by default
|
|
93
|
+
|
|
94
|
+
// Accuracy — correlated with confidence unless contradicted
|
|
95
|
+
signals.accuracy = signals.confidence;
|
|
96
|
+
|
|
97
|
+
return signals;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Infer limitation flags from raw result + tool name.
|
|
102
|
+
*/
|
|
103
|
+
export function inferLimitations(raw: RawToolResult): LimitationFlags {
|
|
104
|
+
const flags: LimitationFlags = {};
|
|
105
|
+
const content = extractTextContent(raw.result);
|
|
106
|
+
const result = raw.result as Record<string, unknown> | undefined;
|
|
107
|
+
|
|
108
|
+
// Truncated: check heuristic + explicit flag
|
|
109
|
+
flags.truncated = isTextTruncated(content) || result?.truncated === true;
|
|
110
|
+
|
|
111
|
+
// Limited scope: partial results, filtered output
|
|
112
|
+
if (result?.details) {
|
|
113
|
+
const details = result.details as Record<string, unknown>;
|
|
114
|
+
if (details.truncated === true) flags.truncated = true;
|
|
115
|
+
if (details.totalCount && details.count) {
|
|
116
|
+
flags.limited_scope = (details.count as number) < (details.totalCount as number);
|
|
117
|
+
}
|
|
118
|
+
// Pagination indicators
|
|
119
|
+
if (details.limit && details.total) {
|
|
120
|
+
flags.limited_scope = (details.limit as number) < (details.total as number);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// API response indicators
|
|
125
|
+
if (result) {
|
|
126
|
+
// HTTP error status
|
|
127
|
+
if (
|
|
128
|
+
(result.status && typeof result.status === "number" && result.status >= 400) ||
|
|
129
|
+
(result.statusCode && typeof result.statusCode === "number" && result.statusCode >= 400)
|
|
130
|
+
) {
|
|
131
|
+
flags.requires_human_review = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Rate limiting
|
|
135
|
+
if (
|
|
136
|
+
result.retryAfter !== undefined ||
|
|
137
|
+
(typeof result.text === "string" && /rate.?limit/i.test(result.text))
|
|
138
|
+
) {
|
|
139
|
+
flags.limited_scope = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Tool-specific inference
|
|
144
|
+
switch (raw.toolName) {
|
|
145
|
+
case "bash": {
|
|
146
|
+
// pi's standard bash tool doesn't include exitCode in details —
|
|
147
|
+
// detect errors from content text patterns instead
|
|
148
|
+
if (looksLikeBashError(content)) {
|
|
149
|
+
flags.requires_human_review = true;
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "read": {
|
|
154
|
+
// Partial reads (details.offset/limit)
|
|
155
|
+
const rDetails = result?.details as Record<string, unknown> | undefined;
|
|
156
|
+
if (rDetails?.offset !== undefined || rDetails?.limit !== undefined) {
|
|
157
|
+
flags.limited_scope = true;
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "browser_navigate":
|
|
162
|
+
case "browser_click":
|
|
163
|
+
case "browser_type":
|
|
164
|
+
case "browser_read":
|
|
165
|
+
case "browser_screenshot":
|
|
166
|
+
case "browser_scroll":
|
|
167
|
+
case "browser_evaluate": {
|
|
168
|
+
// Browser navigations may have HTTP errors
|
|
169
|
+
if (
|
|
170
|
+
typeof content === "string" &&
|
|
171
|
+
(content.includes("Error:") || content.includes("ERR_") || content.includes("timeout"))
|
|
172
|
+
) {
|
|
173
|
+
flags.limited_scope = true;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "write":
|
|
178
|
+
case "edit": {
|
|
179
|
+
// File writes are usually fine but check for partial writes
|
|
180
|
+
const wDetails = result?.details as Record<string, unknown> | undefined;
|
|
181
|
+
if (wDetails?.bytesWritten && wDetails?.bytesExpected) {
|
|
182
|
+
flags.truncated =
|
|
183
|
+
(wDetails.bytesWritten as number) < (wDetails.bytesExpected as number);
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return flags;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generate warnings from the inferred signals + tool context.
|
|
194
|
+
*/
|
|
195
|
+
export function inferWarnings(
|
|
196
|
+
raw: RawToolResult,
|
|
197
|
+
quality: QualitySignals,
|
|
198
|
+
limitations: LimitationFlags,
|
|
199
|
+
config: AwarenessConfig,
|
|
200
|
+
): string[] {
|
|
201
|
+
const warnings: string[] = [];
|
|
202
|
+
|
|
203
|
+
// Confidence warnings
|
|
204
|
+
if (quality.confidence !== undefined && quality.confidence < config.thresholds.lowConfidence) {
|
|
205
|
+
warnings.push(
|
|
206
|
+
`Low confidence (${(quality.confidence * 100).toFixed(0)}%) — result may be unreliable`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Freshness warnings
|
|
211
|
+
if (quality.freshness !== undefined && quality.freshness < config.thresholds.staleFreshness) {
|
|
212
|
+
warnings.push("Data may be stale — freshness score is low");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Completeness warnings
|
|
216
|
+
if (quality.completeness === "minimal") {
|
|
217
|
+
warnings.push("Minimal result returned — data may be incomplete");
|
|
218
|
+
} else if (quality.completeness === "partial") {
|
|
219
|
+
warnings.push("Partial result — not all requested data was returned");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Truncation
|
|
223
|
+
if (limitations.truncated) {
|
|
224
|
+
warnings.push("Output was truncated — some content may be missing");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Limited scope
|
|
228
|
+
if (limitations.limited_scope) {
|
|
229
|
+
warnings.push("Result is scoped/limited — may not cover full request");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Human review
|
|
233
|
+
if (limitations.requires_human_review) {
|
|
234
|
+
warnings.push("Human review recommended before acting on this result");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Sensitive content
|
|
238
|
+
if (limitations.sensitive_content) {
|
|
239
|
+
warnings.push("Result may contain sensitive data — handle with care");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Staleness (explicit flag)
|
|
243
|
+
if (limitations.stale) {
|
|
244
|
+
warnings.push("Data may be outdated — verify freshness");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Tool-specific warnings
|
|
248
|
+
const result = raw.result as Record<string, unknown> | undefined;
|
|
249
|
+
const detailsWarn = result?.details as Record<string, unknown> | undefined;
|
|
250
|
+
const content = extractTextContent(raw.result);
|
|
251
|
+
|
|
252
|
+
switch (raw.toolName) {
|
|
253
|
+
case "bash": {
|
|
254
|
+
if (looksLikeBashError(content)) {
|
|
255
|
+
// Extract the first error line for the warning
|
|
256
|
+
const errLine = content.split("\n").find(l =>
|
|
257
|
+
/error|Error|ERROR|cannot|denied|not found|No such/i.test(l)
|
|
258
|
+
);
|
|
259
|
+
if (errLine) {
|
|
260
|
+
warnings.push(`Bash error: ${errLine.trim().substring(0, 100)}`);
|
|
261
|
+
} else {
|
|
262
|
+
warnings.push("Command produced error output");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (detailsWarn?.timedOut) {
|
|
266
|
+
warnings.push("Command timed out");
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case "read": {
|
|
271
|
+
if (detailsWarn?.truncated) {
|
|
272
|
+
warnings.push("File read was truncated at limit");
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case "browser_navigate":
|
|
277
|
+
case "browser_read": {
|
|
278
|
+
if (content.includes("screenshot/OCR failed") || content.includes("OCR unavailable")) {
|
|
279
|
+
warnings.push("OCR/rendering failure — page content may be incomplete");
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "ci_get_logs":
|
|
284
|
+
case "ci_list_runs":
|
|
285
|
+
case "ci_get_run": {
|
|
286
|
+
if (content.includes("❌") || content.includes("Failed")) {
|
|
287
|
+
warnings.push("CI API returned errors — check Gitea connectivity");
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "doctor_audit":
|
|
292
|
+
case "doctor_check_file": {
|
|
293
|
+
// Doctor tools are informational — no warnings needed
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return warnings;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Generate actionable suggestions based on warnings and tool type.
|
|
303
|
+
*/
|
|
304
|
+
export function inferSuggestions(
|
|
305
|
+
raw: RawToolResult,
|
|
306
|
+
warnings: string[],
|
|
307
|
+
quality: QualitySignals,
|
|
308
|
+
): string[] {
|
|
309
|
+
const suggestions: string[] = [];
|
|
310
|
+
|
|
311
|
+
if (warnings.length === 0) return suggestions;
|
|
312
|
+
|
|
313
|
+
// Generic suggestions based on limitation types
|
|
314
|
+
if (quality.completeness === "partial" || quality.completeness === "minimal") {
|
|
315
|
+
suggestions.push("Consider retrying with narrower scope or pagination parameters");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (quality.freshness !== undefined && quality.freshness < 0.3) {
|
|
319
|
+
suggestions.push("Verify time-sensitive claims against a real-time source");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Tool-specific suggestions
|
|
323
|
+
switch (raw.toolName) {
|
|
324
|
+
case "bash": {
|
|
325
|
+
const result = raw.result as Record<string, unknown> | undefined;
|
|
326
|
+
const details = result?.details as Record<string, unknown> | undefined;
|
|
327
|
+
const bashContent = extractTextContent(raw.result);
|
|
328
|
+
if (looksLikeBashError(bashContent)) {
|
|
329
|
+
suggestions.push("Check the error output above and adjust the command");
|
|
330
|
+
}
|
|
331
|
+
if (details?.timedOut) {
|
|
332
|
+
suggestions.push("Increase timeout or break command into smaller operations");
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "read": {
|
|
337
|
+
if (raw.details?.truncated) {
|
|
338
|
+
suggestions.push("Use offset and limit parameters to read remaining content");
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case "browser_navigate":
|
|
343
|
+
case "browser_click":
|
|
344
|
+
case "browser_read": {
|
|
345
|
+
if (extractTextContent(raw.result).includes("OCR unavailable")) {
|
|
346
|
+
suggestions.push("Install tesseract.js for OCR support: npm install tesseract.js");
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "ci_get_logs":
|
|
351
|
+
case "ci_list_runs": {
|
|
352
|
+
if (extractTextContent(raw.result).includes("Failed")) {
|
|
353
|
+
suggestions.push("Verify Gitea instance is running and reachable");
|
|
354
|
+
suggestions.push("Check .circ.yml configuration for correct Gitea URL");
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return suggestions;
|
|
361
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-tool-awareness-gate — Types
|
|
3
|
+
*
|
|
4
|
+
* Universal ToolResult envelope for all tool invocations.
|
|
5
|
+
* Gates can import these types for domain-specific enrichment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Tool Result Envelope ──────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface ToolResultMeta {
|
|
11
|
+
/** Name of the tool that produced this result */
|
|
12
|
+
tool_name: string;
|
|
13
|
+
/** Unique invocation identifier */
|
|
14
|
+
invocation_id: string;
|
|
15
|
+
/** Wall-clock latency in milliseconds */
|
|
16
|
+
latency_ms: number;
|
|
17
|
+
/** Number of retries before this result */
|
|
18
|
+
retries: number;
|
|
19
|
+
/** ISO 8601 timestamp of invocation start */
|
|
20
|
+
timestamp: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QualitySignals {
|
|
24
|
+
/** 0-1 confidence in the result correctness */
|
|
25
|
+
confidence?: number;
|
|
26
|
+
/** How complete the result is relative to what was requested */
|
|
27
|
+
completeness?: "full" | "partial" | "minimal" | "unknown";
|
|
28
|
+
/** 0-1 freshness — 1 = real-time, 0 = stale/unknown */
|
|
29
|
+
freshness?: number;
|
|
30
|
+
/** 0-1 estimated accuracy */
|
|
31
|
+
accuracy?: number;
|
|
32
|
+
/** Additional tool-specific quality fields */
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LimitationFlags {
|
|
37
|
+
/** Result was truncated (output, pagination, context window) */
|
|
38
|
+
truncated?: boolean;
|
|
39
|
+
/** Data may be stale/outdated */
|
|
40
|
+
stale?: boolean;
|
|
41
|
+
/** Result covers only a subset of what was requested */
|
|
42
|
+
limited_scope?: boolean;
|
|
43
|
+
/** Human review recommended before acting on this result */
|
|
44
|
+
requires_human_review?: boolean;
|
|
45
|
+
/** Result may contain sensitive data */
|
|
46
|
+
sensitive_content?: boolean;
|
|
47
|
+
/** Additional tool-specific limitation flags */
|
|
48
|
+
[key: string]: boolean | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ToolResultStatus =
|
|
52
|
+
| "success" // Complete, no known issues
|
|
53
|
+
| "partial" // Incomplete or degraded
|
|
54
|
+
| "failure" // Tool execution failed
|
|
55
|
+
| "timeout" // Tool timed out
|
|
56
|
+
| "unauthorized"; // Auth/permission error
|
|
57
|
+
|
|
58
|
+
export interface ToolResultEnvelope<T = unknown> {
|
|
59
|
+
/** Deterministic status for control flow decisions */
|
|
60
|
+
status: ToolResultStatus;
|
|
61
|
+
/** The raw tool output payload */
|
|
62
|
+
result: T;
|
|
63
|
+
/** Execution metadata */
|
|
64
|
+
meta: ToolResultMeta;
|
|
65
|
+
/** Quality signals — how reliable is this result? */
|
|
66
|
+
quality: QualitySignals;
|
|
67
|
+
/** Binary limitation flags — what should the agent be cautious about? */
|
|
68
|
+
limitations: LimitationFlags;
|
|
69
|
+
/** Human + agent readable warnings */
|
|
70
|
+
warnings: string[];
|
|
71
|
+
/** Actionable next steps for the agent */
|
|
72
|
+
suggestions?: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Short Reminder ───────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface ShortReminder {
|
|
78
|
+
/** Tool name */
|
|
79
|
+
tool: string;
|
|
80
|
+
/** 1-2 sentence summary of limitations */
|
|
81
|
+
summary: string;
|
|
82
|
+
/** Severity level */
|
|
83
|
+
level: "info" | "warn" | "critical";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Awareness Config ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export interface AwarenessConfig {
|
|
89
|
+
/** Enable/disable the awareness layer */
|
|
90
|
+
enabled: boolean;
|
|
91
|
+
/** Inject short reminders into agent context (default: true) */
|
|
92
|
+
injectReminders: boolean;
|
|
93
|
+
/** Log rich payloads for evaluation (default: true) */
|
|
94
|
+
logEnvelopes: boolean;
|
|
95
|
+
/** Path for envelope log (relative to cwd) */
|
|
96
|
+
envelopeLogPath: string;
|
|
97
|
+
/** Max warnings to include per short reminder */
|
|
98
|
+
maxWarningsInReminder: number;
|
|
99
|
+
/** Severity thresholds for quality signals */
|
|
100
|
+
thresholds: {
|
|
101
|
+
/** Confidence below this triggers a warning */
|
|
102
|
+
lowConfidence: number;
|
|
103
|
+
/** Freshness below this triggers a staleness warning */
|
|
104
|
+
staleFreshness: number;
|
|
105
|
+
/** Max warnings before escalating to critical */
|
|
106
|
+
maxWarningsBeforeCritical: number;
|
|
107
|
+
};
|
|
108
|
+
/** Tools to skip awareness tracking */
|
|
109
|
+
excludedTools: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const DEFAULT_AWARENESS_CONFIG: AwarenessConfig = {
|
|
113
|
+
enabled: true,
|
|
114
|
+
injectReminders: true,
|
|
115
|
+
logEnvelopes: true,
|
|
116
|
+
envelopeLogPath: ".awareness/envelopes.log",
|
|
117
|
+
maxWarningsInReminder: 3,
|
|
118
|
+
thresholds: {
|
|
119
|
+
lowConfidence: 0.5,
|
|
120
|
+
staleFreshness: 0.3,
|
|
121
|
+
maxWarningsBeforeCritical: 5,
|
|
122
|
+
},
|
|
123
|
+
excludedTools: [],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// ── Inference Input (what we have to work with from the tool_result event) ──
|
|
127
|
+
|
|
128
|
+
export interface RawToolResult {
|
|
129
|
+
toolName: string;
|
|
130
|
+
result: unknown;
|
|
131
|
+
error?: unknown;
|
|
132
|
+
/** If the tool has isError flag */
|
|
133
|
+
isError?: boolean;
|
|
134
|
+
/** Content array if present */
|
|
135
|
+
content?: Array<{ type: string; text?: string }>;
|
|
136
|
+
/** Details object if present */
|
|
137
|
+
details?: Record<string, unknown>;
|
|
138
|
+
}
|