pi-delegation-policy 0.9.0 → 0.9.1
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/CHANGELOG.md +7 -1
- package/CONTRIBUTING.md +2 -1
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/examples/global.json +1 -2
- package/package.json +1 -1
- package/schema/delegation-policy.schema.json +5 -1
- package/src/config.ts +5 -4
- package/src/context-shunt-adapter.ts +43 -9
- package/src/context-shunt.ts +146 -75
- package/src/delegate-panel.ts +0 -5
- package/src/index.ts +47 -9
- package/src/types.ts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 0.9.1 - 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Make ContextShunt declared-read limits line-based, measure known textual results with real UTF-8 bytes and shared newline handling, and keep rejected requests out of the admitted-request window.
|
|
10
|
+
- Keep the one-time `allow TOKEN MAX_LINES MAX_BYTES` command while binding its authorization to one tool call and immutable input snapshot; enforce its byte maximum against the real result.
|
|
11
|
+
- Continue accepting the deprecated schema-4 `readerOutputBytes` field for compatibility while ignoring it, omitting it from new saves, and removing its inert panel control.
|
|
6
12
|
|
|
7
13
|
## 0.9.0 - 2026-09-07
|
|
8
14
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -19,7 +19,8 @@ Pull requests should:
|
|
|
19
19
|
- use fictional examples and provider-agnostic documentation;
|
|
20
20
|
- preserve exact role references and the absence of model fallbacks;
|
|
21
21
|
- preserve the boundary: this package does not create, launch, route, or supervise subagents; ContextShunt can only redirect the main agent and never starts a worker from a hook;
|
|
22
|
-
- preserve ContextShunt defaults and controls: `off` performs no classification, metrics, I/O, or interception;
|
|
22
|
+
- preserve ContextShunt defaults and controls: `off` performs no classification, metrics, I/O, or interception; declared preflight limits count lines, post-result limits use returned UTF-8 bytes and lines, and rejected requests do not consume the admitted-request window;
|
|
23
|
+
- preserve originals when an artifact, permission, or recognized contract is unavailable; keep one-time exceptions user-authorized, short-lived, and bound to the exact tool call and input, with byte limits checked against the returned result;
|
|
23
24
|
- avoid project configuration, credential handling, telemetry, and network requests;
|
|
24
25
|
- run `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build`.
|
|
25
26
|
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ pi install npm:pi-delegation-policy
|
|
|
39
39
|
4. Run `/delegate status`. `disabled`, `not configured`, and exact references remain distinct. `D:ERR` means no policy is injected.
|
|
40
40
|
5. The applied state affects the **next** agent run.
|
|
41
41
|
|
|
42
|
-
Global defaults are stored at `~/.pi/agent/delegation-policy.json` and new values use schema version 4. Schema 2 and 3 defaults and session entries are read and normalized in memory without rewriting them. Schema 3 stores `null` for an explicitly disabled ordinary role. Session changes write a schema 2 `off` guard before the schema 4 state; saving defaults changes only the global file.
|
|
42
|
+
Global defaults are stored at `~/.pi/agent/delegation-policy.json` and new values use schema version 4. The legacy positive `contextShunt.limits.readerOutputBytes` field remains accepted when reading schema 4, but is ignored and omitted from new saves. Schema 2 and 3 defaults and session entries are read and normalized in memory without rewriting them. Schema 3 stores `null` for an explicitly disabled ordinary role. Session changes write a schema 2 `off` guard before the schema 4 state; saving defaults changes only the global file.
|
|
43
43
|
|
|
44
44
|
Before downgrading to a package that does not read schema 4, set global and branch ContextShunt to `off`; the guarded branch write already presents schema 2 `off` to older versions. Before downgrading to `0.6.0`, also change the global intensity to `off`, `normal`, or `aggressive` and run `/delegate off` in every active branch. For `<=0.5.0`, convert global defaults to schema 2 and replace ordinary `null` values with exact model references. Schema 2 never accepts `orchestrator`. See the configuration reference for details.
|
|
45
45
|
|
|
@@ -63,7 +63,7 @@ See the [getting-started guide](https://yivas.github.io/pi-delegation-policy/get
|
|
|
63
63
|
|
|
64
64
|
The editor is a bounded, keyboard-first panel. Every model selector pins **Use global default** and **Disable for this session** before searchable models. It shows model ID first and `[provider]` last, fuzzy-searches provider, model ID, and display name, and shows at most 10 model rows. It also shows a compact effective-policy preview, field explanations, and public model metadata when Pi supplies it. Changes are drafts until **Apply changes**; saving effective configuration as defaults updates only the global file without applying the draft, and closing a modified draft requires explicit discard.
|
|
65
65
|
|
|
66
|
-
**Context advanced** in `/delegate` edits the requested reader role, limits, and comma-separated patterns, with per-field global inheritance and a ContextShunt draft reset. In `enforce`, an oversized recognized read receives a bounded-read or user-confirmed one-time exception path. Large known text results are compacted only after their original is stored in a private, session-only artifact with a quota and an absolute 30-minute TTL from creation; scheduled cleanup runs while the process is active, recovery does not renew it, and shutdown removes the temporary directory. Crashes or OS suspension can delay deletion. `context_shunt_recover` accepts either a bounded line range or byte range. Errors, valid JSON of every root type, images, mixed content, and unknown tool contracts remain unchanged. The package declares `agents/pi-delegation-policy.bulk-reader.md` for discovery by a compatible executor. It allows only `read`, `grep`, `find`, and `ls`; it remains a guided profile, not an automatic bridge or a claim of executor isolation. It is not copied into user agent directories or launched automatically. If an executor cannot discover a path-based profile, use the guided redirection only.
|
|
66
|
+
**Context advanced** in `/delegate` edits the requested reader role, limits, and comma-separated patterns, with per-field global inheritance and a ContextShunt draft reset. In `enforce`, an oversized recognized read receives a bounded-read or user-confirmed one-time exception path. Preguards use declared lines, while postguards use real UTF-8 bytes and returned line counts; rejected reads do not consume the shared declared-request window. Large known text results are compacted only after their original is stored in a private, session-only artifact with a quota and an absolute 30-minute TTL from creation; scheduled cleanup runs while the process is active, recovery does not renew it, and shutdown removes the temporary directory. Crashes or OS suspension can delay deletion. `context_shunt_recover` accepts either a bounded line range or byte range. Errors, valid JSON of every root type, images, mixed content, and unknown tool contracts remain unchanged. The package declares `agents/pi-delegation-policy.bulk-reader.md` for discovery by a compatible executor. It allows only `read`, `grep`, `find`, and `ls`; it remains a guided profile, not an automatic bridge or a claim of executor isolation. It is not copied into user agent directories or launched automatically. If an executor cannot discover a path-based profile, use the guided redirection only.
|
|
67
67
|
|
|
68
68
|
## Development
|
|
69
69
|
|
package/SECURITY.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
This project is a local Pi extension. It stores delegation policy data and model identifiers in global defaults and session entries. ContextShunt is off by default. When explicitly enforced, it may keep a known successful text result in a private, session-only temporary file to serve bounded recovery; the file has an opaque ID, quota, cancellation check, and an absolute 30-minute TTL from creation. Cleanup is scheduled while the process is active and runs at shutdown, but crashes or OS suspension can delay deletion. It does not store credentials, execute subagents, or make network requests.
|
|
6
6
|
|
|
7
|
-
ContextShunt is not a sandbox or a worker bridge. It preserves permissions and backends, does not inspect files before tool authorization, and leaves errors, structured/mixed results, images, binaries, and unknown contracts unchanged. The policy guides the main agent. It cannot guarantee that another system will follow a configured role or thinking choice. Review local configuration before using it.
|
|
7
|
+
ContextShunt is not a sandbox or a worker bridge. It preserves permissions and backends, does not inspect files before tool authorization, and leaves errors, structured/mixed results, images, binaries, invalid inputs, and unknown contracts unchanged. One-time exceptions are user-authorized, short-lived, bound to one call and immutable input snapshot, and capped by declared lines plus real returned UTF-8 bytes. The policy guides the main agent. It cannot guarantee that another system will follow a configured role or thinking choice. Review local configuration before using it.
|
|
8
8
|
|
|
9
9
|
## Reporting
|
|
10
10
|
|
package/examples/global.json
CHANGED
package/package.json
CHANGED
|
@@ -48,7 +48,11 @@
|
|
|
48
48
|
"fullReadBytes": { "$ref": "#/$defs/positiveLimit" },
|
|
49
49
|
"targetedReadLines": { "$ref": "#/$defs/positiveLimit" },
|
|
50
50
|
"targetedReadBytes": { "$ref": "#/$defs/positiveLimit" },
|
|
51
|
-
"readerOutputBytes": {
|
|
51
|
+
"readerOutputBytes": {
|
|
52
|
+
"$ref": "#/$defs/positiveLimit",
|
|
53
|
+
"deprecated": true,
|
|
54
|
+
"description": "Legacy accepted field. It is validated then ignored and is not written by current versions."
|
|
55
|
+
}
|
|
52
56
|
},
|
|
53
57
|
"additionalProperties": false
|
|
54
58
|
},
|
package/src/config.ts
CHANGED
|
@@ -46,8 +46,8 @@ const DEFAULT_LIMITS = {
|
|
|
46
46
|
fullReadBytes: 16384,
|
|
47
47
|
targetedReadLines: 250,
|
|
48
48
|
targetedReadBytes: 16384,
|
|
49
|
-
readerOutputBytes: 8192,
|
|
50
49
|
} as const;
|
|
50
|
+
const LEGACY_LIMIT_KEYS = [...Object.keys(DEFAULT_LIMITS), "readerOutputBytes"] as const;
|
|
51
51
|
const MAX_LIMIT = 1024 * 1024;
|
|
52
52
|
|
|
53
53
|
export type ConfigDiagnostic = { message: string; reportWhenOff?: boolean };
|
|
@@ -154,10 +154,11 @@ function parseShunt(value: unknown): ContextShuntSettings | undefined {
|
|
|
154
154
|
if (value.metrics !== undefined && value.metrics !== "memory") return undefined;
|
|
155
155
|
let limits: ContextShuntLimits | undefined;
|
|
156
156
|
if (value.limits !== undefined) {
|
|
157
|
-
if (!isRecord(value.limits) || !only(value.limits,
|
|
158
|
-
return undefined;
|
|
157
|
+
if (!isRecord(value.limits) || !only(value.limits, LEGACY_LIMIT_KEYS)) return undefined;
|
|
159
158
|
if (Object.values(value.limits).some((item) => !validLimit(item))) return undefined;
|
|
160
|
-
|
|
159
|
+
const effectiveLimits = { ...value.limits };
|
|
160
|
+
delete effectiveLimits.readerOutputBytes;
|
|
161
|
+
if (Object.keys(effectiveLimits).length) limits = effectiveLimits as ContextShuntLimits;
|
|
161
162
|
}
|
|
162
163
|
const exceptionPatterns =
|
|
163
164
|
value.exceptionPatterns === undefined ? undefined : patterns(value.exceptionPatterns);
|
|
@@ -36,10 +36,13 @@ export class ContextShuntAdapter {
|
|
|
36
36
|
settings: EffectiveContextShunt,
|
|
37
37
|
isBuiltinTool = true,
|
|
38
38
|
): { block: true; reason: string } | undefined {
|
|
39
|
-
if (settings.mode === "off"
|
|
39
|
+
if (settings.mode === "off") {
|
|
40
|
+
this.clearPending();
|
|
40
41
|
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (settings.mode === "enforce" && !isBuiltinTool) return undefined;
|
|
41
44
|
|
|
42
|
-
const decision = this.engine.decide(event.toolName, event.input, settings);
|
|
45
|
+
const decision = this.engine.decide(event.toolName, event.input, settings, event.toolCallId);
|
|
43
46
|
if (decision.action !== "block") return undefined;
|
|
44
47
|
|
|
45
48
|
this.expirePending();
|
|
@@ -73,7 +76,13 @@ export class ContextShuntAdapter {
|
|
|
73
76
|
const pending = this.pending.get(token);
|
|
74
77
|
this.pending.delete(token);
|
|
75
78
|
if (!pending || pending.expiresAt <= this.now()) return false;
|
|
76
|
-
return this.engine.allowOnce(
|
|
79
|
+
return this.engine.allowOnce(
|
|
80
|
+
pending.toolName,
|
|
81
|
+
pending.input,
|
|
82
|
+
maxLines,
|
|
83
|
+
maxBytes,
|
|
84
|
+
pending.expiresAt,
|
|
85
|
+
);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
async onToolResult(
|
|
@@ -82,12 +91,29 @@ export class ContextShuntAdapter {
|
|
|
82
91
|
signal?: AbortSignal,
|
|
83
92
|
isBuiltinTool = true,
|
|
84
93
|
): Promise<ToolResultPatch | undefined> {
|
|
85
|
-
if (settings.mode !== "enforce" || !isBuiltinTool || signal?.aborted)
|
|
94
|
+
if (settings.mode !== "enforce" || !isBuiltinTool || signal?.aborted) {
|
|
95
|
+
this.engine.clearCall(event.toolCallId);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
86
98
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
const exception = this.engine.takeConsumed(event.toolCallId, event.toolName, event.input);
|
|
100
|
+
const decision = shouldCompact(
|
|
101
|
+
event.toolName,
|
|
102
|
+
event.input,
|
|
103
|
+
event.isError,
|
|
104
|
+
event.content,
|
|
105
|
+
settings,
|
|
106
|
+
exception,
|
|
107
|
+
);
|
|
108
|
+
if (decision.kind === "skip") {
|
|
109
|
+
if (decision.reason.startsWith("uncovered")) this.engine.metrics.uncoveredResults += 1;
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const artifactId = await this.artifacts.archive(decision.text, signal);
|
|
113
|
+
if (!artifactId) {
|
|
114
|
+
this.engine.metrics.archiveFailures += 1;
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
91
117
|
|
|
92
118
|
this.engine.metrics.boundedResults += 1;
|
|
93
119
|
return {
|
|
@@ -113,9 +139,17 @@ export class ContextShuntAdapter {
|
|
|
113
139
|
return "error" in result ? result : { text: result.text, range: result.range };
|
|
114
140
|
}
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
clearConsumed(): void {
|
|
143
|
+
this.engine.clearConsumed();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clearPending(): void {
|
|
117
147
|
this.pending.clear();
|
|
118
148
|
this.engine.clear();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async close(): Promise<void> {
|
|
152
|
+
this.clearPending();
|
|
119
153
|
await this.artifacts.close();
|
|
120
154
|
}
|
|
121
155
|
|
package/src/context-shunt.ts
CHANGED
|
@@ -10,6 +10,7 @@ const MAX_ARTIFACTS = 8;
|
|
|
10
10
|
const ARTIFACT_TTL_MS = 30 * 60 * 1000;
|
|
11
11
|
const WINDOW_TTL_MS = 60_000;
|
|
12
12
|
const EXCEPTION_TTL_MS = 60_000;
|
|
13
|
+
const MAX_CONSUMED_EXCEPTIONS = 8;
|
|
13
14
|
|
|
14
15
|
export type ShuntDecision = { action: "allow" | "block" | "skip"; reason: string };
|
|
15
16
|
export type ShuntMetrics = {
|
|
@@ -17,16 +18,19 @@ export type ShuntMetrics = {
|
|
|
17
18
|
wouldBlock: number;
|
|
18
19
|
boundedResults: number;
|
|
19
20
|
manualOverrides: number;
|
|
21
|
+
archiveFailures: number;
|
|
22
|
+
uncoveredResults: number;
|
|
20
23
|
};
|
|
21
|
-
type Window = { requestedLines: number;
|
|
24
|
+
type Window = { requestedLines: number; updatedAt: number };
|
|
22
25
|
type Exception = { expiresAt: number; maxLines: number; maxBytes: number };
|
|
26
|
+
export type ConsumedException = Exception & { toolName: string; input: string };
|
|
23
27
|
type Clock = () => number;
|
|
24
28
|
|
|
25
29
|
function positiveInteger(value: unknown): value is number {
|
|
26
|
-
return typeof value === "number" && Number.
|
|
30
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
type ReadInput = { path: string; offset?: number; limit?: number };
|
|
33
|
+
type ReadInput = { path: string; offset?: number; limit?: number; targeted: boolean };
|
|
30
34
|
|
|
31
35
|
function readInput(value: unknown): ReadInput | undefined {
|
|
32
36
|
if (!value || typeof value !== "object") return undefined;
|
|
@@ -34,7 +38,7 @@ function readInput(value: unknown): ReadInput | undefined {
|
|
|
34
38
|
if (typeof input.path !== "string" || !input.path) return undefined;
|
|
35
39
|
if (
|
|
36
40
|
input.offset !== undefined &&
|
|
37
|
-
(typeof input.offset !== "number" || !Number.
|
|
41
|
+
(typeof input.offset !== "number" || !Number.isSafeInteger(input.offset) || input.offset < 1)
|
|
38
42
|
) {
|
|
39
43
|
return undefined;
|
|
40
44
|
}
|
|
@@ -43,6 +47,7 @@ function readInput(value: unknown): ReadInput | undefined {
|
|
|
43
47
|
path: input.path,
|
|
44
48
|
...(typeof input.offset === "number" ? { offset: input.offset } : {}),
|
|
45
49
|
...(typeof input.limit === "number" ? { limit: input.limit } : {}),
|
|
50
|
+
targeted: input.offset !== undefined,
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
|
|
@@ -59,7 +64,7 @@ function shellReadInput(toolName: string, value: unknown): ReadInput | undefined
|
|
|
59
64
|
if (!match?.groups?.path || !match.groups.limit) return undefined;
|
|
60
65
|
const limit = Number(match.groups.limit);
|
|
61
66
|
return Number.isSafeInteger(limit) && positiveInteger(limit)
|
|
62
|
-
? { path: `shell:${match.groups.path}`, limit }
|
|
67
|
+
? { path: `shell:${match.groups.path}`, limit, targeted: false }
|
|
63
68
|
: undefined;
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -79,107 +84,143 @@ function matchesPattern(path: string, patterns: readonly string[]): boolean {
|
|
|
79
84
|
});
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
export function inputFingerprint(toolName: string, input: unknown): string {
|
|
83
|
-
|
|
87
|
+
export function inputFingerprint(toolName: string, input: unknown): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const serialized = JSON.stringify(input);
|
|
90
|
+
return serialized === undefined ? undefined : `${toolName}\u0000${serialized}`;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
export class ContextShuntEngine {
|
|
87
97
|
private readonly windows = new Map<string, Window>();
|
|
88
98
|
private readonly exceptions = new Map<string, Exception>();
|
|
99
|
+
private readonly consumed = new Map<string, ConsumedException>();
|
|
89
100
|
private readonly now: Clock;
|
|
90
101
|
readonly metrics: ShuntMetrics = {
|
|
91
102
|
blocked: 0,
|
|
92
103
|
wouldBlock: 0,
|
|
93
104
|
boundedResults: 0,
|
|
94
105
|
manualOverrides: 0,
|
|
106
|
+
archiveFailures: 0,
|
|
107
|
+
uncoveredResults: 0,
|
|
95
108
|
};
|
|
96
109
|
|
|
97
110
|
constructor(now: Clock = Date.now) {
|
|
98
111
|
this.now = now;
|
|
99
112
|
}
|
|
100
113
|
|
|
101
|
-
decide(
|
|
114
|
+
decide(
|
|
115
|
+
toolName: string,
|
|
116
|
+
input: unknown,
|
|
117
|
+
settings: EffectiveContextShunt,
|
|
118
|
+
toolCallId?: string,
|
|
119
|
+
): ShuntDecision {
|
|
102
120
|
if (settings.mode === "off") return { action: "skip", reason: "off" };
|
|
103
121
|
|
|
104
122
|
const read = declaredRead(toolName, input);
|
|
105
|
-
if (!read) return { action: "skip", reason: "
|
|
123
|
+
if (!read) return { action: "skip", reason: "unknown-contract" };
|
|
106
124
|
|
|
107
|
-
this.
|
|
125
|
+
const now = this.now();
|
|
126
|
+
this.expire(now);
|
|
108
127
|
const fingerprint = inputFingerprint(toolName, input);
|
|
109
|
-
const exception = this.exceptions.get(fingerprint);
|
|
110
|
-
if (exception) {
|
|
128
|
+
const exception = fingerprint ? this.exceptions.get(fingerprint) : undefined;
|
|
129
|
+
if (exception && fingerprint && read.limit !== undefined) {
|
|
111
130
|
this.exceptions.delete(fingerprint);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
if (exception.expiresAt > now && read.limit <= exception.maxLines && toolCallId) {
|
|
132
|
+
this.consumed.delete(toolCallId);
|
|
133
|
+
if (this.consumed.size >= MAX_CONSUMED_EXCEPTIONS) {
|
|
134
|
+
const oldestToolCallId = this.consumed.keys().next().value;
|
|
135
|
+
if (oldestToolCallId) this.consumed.delete(oldestToolCallId);
|
|
136
|
+
}
|
|
137
|
+
this.consumed.set(toolCallId, { ...exception, toolName, input: fingerprint });
|
|
119
138
|
this.metrics.manualOverrides += 1;
|
|
120
|
-
return { action: "allow", reason: "one-time
|
|
139
|
+
return { action: "allow", reason: "one-time-exception" };
|
|
121
140
|
}
|
|
122
141
|
}
|
|
123
142
|
|
|
124
143
|
if (matchesPattern(read.path, settings.exceptionPatterns)) {
|
|
125
|
-
return {
|
|
126
|
-
action: "allow",
|
|
127
|
-
reason: "matches ContextShunt exemption; tool permissions unchanged",
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (read.limit === undefined) {
|
|
132
|
-
return { action: "allow", reason: "size unknown; post-result guard applies" };
|
|
144
|
+
return { action: "allow", reason: "configured-exemption" };
|
|
133
145
|
}
|
|
146
|
+
if (read.limit === undefined) return { action: "allow", reason: "size-unknown" };
|
|
134
147
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const byteBudget = targeted ? settings.limits.targetedReadBytes : settings.limits.fullReadBytes;
|
|
148
|
+
const lineBudget = read.targeted
|
|
149
|
+
? settings.limits.targetedReadLines
|
|
150
|
+
: settings.limits.fullReadLines;
|
|
139
151
|
const windowKey = `${toolName}\u0000${read.path}`;
|
|
140
152
|
const previous = this.windows.get(windowKey);
|
|
141
153
|
const totalLines = (previous?.requestedLines ?? 0) + read.limit;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
read.limit > lineBudget ||
|
|
145
|
-
requestedBytes > byteBudget ||
|
|
146
|
-
totalLines > settings.limits.fullReadLines ||
|
|
147
|
-
totalBytes > settings.limits.fullReadBytes;
|
|
148
|
-
this.windows.set(windowKey, {
|
|
149
|
-
requestedLines: totalLines,
|
|
150
|
-
requestedBytes: totalBytes,
|
|
151
|
-
updatedAt: this.now(),
|
|
152
|
-
});
|
|
154
|
+
const excessive = read.limit > lineBudget || totalLines > settings.limits.fullReadLines;
|
|
155
|
+
|
|
153
156
|
if (settings.mode === "observe") {
|
|
154
|
-
if (excessive)
|
|
157
|
+
if (excessive) {
|
|
158
|
+
this.metrics.wouldBlock += 1;
|
|
159
|
+
return { action: "allow", reason: "would-block-lines" };
|
|
160
|
+
}
|
|
161
|
+
this.windows.set(windowKey, { requestedLines: totalLines, updatedAt: now });
|
|
162
|
+
return { action: "allow", reason: "declared-bounded" };
|
|
163
|
+
}
|
|
164
|
+
if (excessive) {
|
|
165
|
+
this.metrics.blocked += 1;
|
|
155
166
|
return {
|
|
156
|
-
action: "
|
|
157
|
-
reason:
|
|
167
|
+
action: "block",
|
|
168
|
+
reason: `${matchesPattern(read.path, settings.delegationHintPatterns) ? "delegation-hinted-" : ""}lines-exceed-budget`,
|
|
158
169
|
};
|
|
159
170
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
action: "block",
|
|
164
|
-
reason: `${
|
|
165
|
-
matchesPattern(read.path, settings.delegationHintPatterns) ? "delegation-hinted " : ""
|
|
166
|
-
}declared range exceeds ContextShunt budget; use a bounded read or a user-authorized one-time exception`,
|
|
167
|
-
};
|
|
171
|
+
this.windows.set(windowKey, { requestedLines: totalLines, updatedAt: now });
|
|
172
|
+
return { action: "allow", reason: "declared-bounded" };
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
allowOnce(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
allowOnce(
|
|
176
|
+
toolName: string,
|
|
177
|
+
input: unknown,
|
|
178
|
+
maxLines: number,
|
|
179
|
+
maxBytes: number,
|
|
180
|
+
expiresAt = this.now() + EXCEPTION_TTL_MS,
|
|
181
|
+
): boolean {
|
|
182
|
+
const fingerprint = inputFingerprint(toolName, input);
|
|
183
|
+
if (
|
|
184
|
+
!positiveInteger(maxLines) ||
|
|
185
|
+
!positiveInteger(maxBytes) ||
|
|
186
|
+
!fingerprint ||
|
|
187
|
+
expiresAt <= this.now()
|
|
188
|
+
)
|
|
189
|
+
return false;
|
|
190
|
+
this.exceptions.set(fingerprint, { expiresAt, maxLines, maxBytes });
|
|
177
191
|
return true;
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
takeConsumed(
|
|
195
|
+
toolCallId: string,
|
|
196
|
+
toolName: string,
|
|
197
|
+
input: unknown,
|
|
198
|
+
): ConsumedException | undefined {
|
|
199
|
+
const exception = this.consumed.get(toolCallId);
|
|
200
|
+
this.consumed.delete(toolCallId);
|
|
201
|
+
if (
|
|
202
|
+
!exception ||
|
|
203
|
+
exception.expiresAt <= this.now() ||
|
|
204
|
+
exception.toolName !== toolName ||
|
|
205
|
+
exception.input !== inputFingerprint(toolName, input)
|
|
206
|
+
) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return exception;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
clearCall(toolCallId: string): void {
|
|
213
|
+
this.consumed.delete(toolCallId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
clearConsumed(): void {
|
|
217
|
+
this.consumed.clear();
|
|
218
|
+
}
|
|
219
|
+
|
|
180
220
|
clear(): void {
|
|
181
221
|
this.windows.clear();
|
|
182
222
|
this.exceptions.clear();
|
|
223
|
+
this.consumed.clear();
|
|
183
224
|
}
|
|
184
225
|
|
|
185
226
|
private expire(now: number): void {
|
|
@@ -189,6 +230,9 @@ export class ContextShuntEngine {
|
|
|
189
230
|
for (const [key, exception] of this.exceptions) {
|
|
190
231
|
if (exception.expiresAt <= now) this.exceptions.delete(key);
|
|
191
232
|
}
|
|
233
|
+
for (const [key, exception] of this.consumed) {
|
|
234
|
+
if (exception.expiresAt <= now) this.consumed.delete(key);
|
|
235
|
+
}
|
|
192
236
|
}
|
|
193
237
|
}
|
|
194
238
|
|
|
@@ -206,7 +250,7 @@ function isUtf8Boundary(source: Buffer, offset: number): boolean {
|
|
|
206
250
|
return byte !== undefined && (byte & 0b1100_0000) !== 0b1000_0000;
|
|
207
251
|
}
|
|
208
252
|
|
|
209
|
-
function splitLines(text: string): string[] {
|
|
253
|
+
export function splitLines(text: string): string[] {
|
|
210
254
|
const lines: string[] = [];
|
|
211
255
|
let start = 0;
|
|
212
256
|
const endings = /\r\n|\n|\r/g;
|
|
@@ -435,23 +479,50 @@ function isJsonDocument(text: string): boolean {
|
|
|
435
479
|
}
|
|
436
480
|
}
|
|
437
481
|
|
|
482
|
+
export type CompactionDecision =
|
|
483
|
+
| { kind: "compact"; text: string; reason: "result-exceeds-budget" | "exception-exceeds-budget" }
|
|
484
|
+
| {
|
|
485
|
+
kind: "skip";
|
|
486
|
+
reason:
|
|
487
|
+
| "off"
|
|
488
|
+
| "uncovered-contract"
|
|
489
|
+
| "uncovered-result"
|
|
490
|
+
| "within-budget"
|
|
491
|
+
| "exception-within-budget";
|
|
492
|
+
};
|
|
493
|
+
|
|
438
494
|
export function shouldCompact(
|
|
439
495
|
toolName: string,
|
|
496
|
+
input: unknown,
|
|
440
497
|
isError: unknown,
|
|
441
498
|
content: unknown,
|
|
442
499
|
settings: EffectiveContextShunt,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
}
|
|
500
|
+
exception?: Pick<ConsumedException, "maxLines" | "maxBytes">,
|
|
501
|
+
): CompactionDecision {
|
|
502
|
+
if (settings.mode !== "enforce") return { kind: "skip", reason: "off" };
|
|
503
|
+
if (!["read", "bash", "powershell", "grep"].includes(toolName))
|
|
504
|
+
return { kind: "skip", reason: "uncovered-contract" };
|
|
505
|
+
const read = declaredRead(toolName, input);
|
|
506
|
+
if (!read) return { kind: "skip", reason: "uncovered-contract" };
|
|
451
507
|
const text = textResult(content);
|
|
452
|
-
if (text === undefined || isJsonDocument(text) || text.includes("\u0000"))
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
508
|
+
if (isError || text === undefined || isJsonDocument(text) || text.includes("\u0000"))
|
|
509
|
+
return { kind: "skip", reason: "uncovered-result" };
|
|
510
|
+
|
|
511
|
+
const targeted = read.targeted;
|
|
512
|
+
const lineBudget =
|
|
513
|
+
exception?.maxLines ??
|
|
514
|
+
(targeted ? settings.limits.targetedReadLines : settings.limits.fullReadLines);
|
|
515
|
+
const byteBudget =
|
|
516
|
+
exception?.maxBytes ??
|
|
517
|
+
(targeted ? settings.limits.targetedReadBytes : settings.limits.fullReadBytes);
|
|
518
|
+
if (text.length === 0) return { kind: "skip", reason: "within-budget" };
|
|
519
|
+
const exceeds =
|
|
520
|
+
Buffer.byteLength(text, "utf8") > byteBudget || splitLines(text).length > lineBudget;
|
|
521
|
+
if (!exceeds)
|
|
522
|
+
return { kind: "skip", reason: exception ? "exception-within-budget" : "within-budget" };
|
|
523
|
+
return {
|
|
524
|
+
kind: "compact",
|
|
525
|
+
text,
|
|
526
|
+
reason: exception ? "exception-exceeds-budget" : "result-exceeds-budget",
|
|
527
|
+
};
|
|
457
528
|
}
|
package/src/delegate-panel.ts
CHANGED
|
@@ -51,7 +51,6 @@ const ADVANCED_FIELDS = [
|
|
|
51
51
|
"fullReadBytes",
|
|
52
52
|
"targetedReadLines",
|
|
53
53
|
"targetedReadBytes",
|
|
54
|
-
"readerOutputBytes",
|
|
55
54
|
"exceptionPatterns",
|
|
56
55
|
"delegationHintPatterns",
|
|
57
56
|
"resetContextShunt",
|
|
@@ -63,7 +62,6 @@ const LIMIT_FIELDS = [
|
|
|
63
62
|
"fullReadBytes",
|
|
64
63
|
"targetedReadLines",
|
|
65
64
|
"targetedReadBytes",
|
|
66
|
-
"readerOutputBytes",
|
|
67
65
|
] as const;
|
|
68
66
|
type LimitField = (typeof LIMIT_FIELDS)[number];
|
|
69
67
|
type EditableAdvancedField = Exclude<AdvancedField, "readerRole" | "resetContextShunt" | "back">;
|
|
@@ -718,7 +716,6 @@ export class DelegatePanel implements Component, Focusable {
|
|
|
718
716
|
case "fullReadBytes":
|
|
719
717
|
case "targetedReadLines":
|
|
720
718
|
case "targetedReadBytes":
|
|
721
|
-
case "readerOutputBytes":
|
|
722
719
|
return String(limit(field));
|
|
723
720
|
case "exceptionPatterns":
|
|
724
721
|
return effective.exceptionPatterns.length
|
|
@@ -757,7 +754,6 @@ export class DelegatePanel implements Component, Focusable {
|
|
|
757
754
|
fullReadBytes: "Full read bytes",
|
|
758
755
|
targetedReadLines: "Targeted read lines",
|
|
759
756
|
targetedReadBytes: "Targeted read bytes",
|
|
760
|
-
readerOutputBytes: "Reader output bytes",
|
|
761
757
|
exceptionPatterns: "Exempt patterns",
|
|
762
758
|
delegationHintPatterns: "Delegation hint patterns",
|
|
763
759
|
resetContextShunt: "Reset ContextShunt draft",
|
|
@@ -807,7 +803,6 @@ export class DelegatePanel implements Component, Focusable {
|
|
|
807
803
|
fullReadBytes: "Full read bytes",
|
|
808
804
|
targetedReadLines: "Targeted read lines",
|
|
809
805
|
targetedReadBytes: "Targeted read bytes",
|
|
810
|
-
readerOutputBytes: "Reader output bytes",
|
|
811
806
|
exceptionPatterns: "Exempt patterns",
|
|
812
807
|
delegationHintPatterns: "Delegation hint patterns",
|
|
813
808
|
};
|
package/src/index.ts
CHANGED
|
@@ -80,19 +80,38 @@ function updateStatus(ctx: ExtensionContext, state: RuntimeState): void {
|
|
|
80
80
|
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", statusLabel(state)));
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function contextStatusText(state: RuntimeState, shunt: ContextShuntAdapter): string {
|
|
84
|
+
const context = state.effective.contextShunt;
|
|
85
|
+
const limits = context.limits;
|
|
86
|
+
const metrics = shunt.engine.metrics;
|
|
87
|
+
return `${statusText(state)} | context-limits=preflight-lines full=${limits.fullReadLines} targeted=${limits.targetedReadLines}; postresult-utf8-bytes full=${limits.fullReadBytes} targeted=${limits.targetedReadBytes} | context-coverage=known builtin text only; unknown contracts and invalid inputs are unchanged | context-events=blocked:${metrics.blocked},would-block:${metrics.wouldBlock},bounded:${metrics.boundedResults},exceptions:${metrics.manualOverrides},archive-failures:${metrics.archiveFailures},uncovered:${metrics.uncoveredResults}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
83
90
|
function isBuiltinTool(pi: ExtensionAPI, toolName: string): boolean {
|
|
84
91
|
return pi
|
|
85
92
|
.getAllTools()
|
|
86
93
|
.some((tool) => tool.name === toolName && tool.sourceInfo.source === "builtin");
|
|
87
94
|
}
|
|
95
|
+
function isContextShuntDisabled(state: RuntimeState): boolean {
|
|
96
|
+
return (
|
|
97
|
+
state.effective.intensity === "off" ||
|
|
98
|
+
state.effective.contextShunt.mode === "off" ||
|
|
99
|
+
state.effective.contextShunt.suspended
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function synchronizeContextShunt(shunt: ContextShuntAdapter, state: RuntimeState): void {
|
|
103
|
+
if (isContextShuntDisabled(state)) shunt.clearPending();
|
|
104
|
+
}
|
|
88
105
|
async function openEditor(
|
|
89
106
|
pi: ExtensionAPI,
|
|
90
107
|
ctx: ExtensionContext,
|
|
91
108
|
setRuntime: (state: RuntimeState) => void,
|
|
109
|
+
shunt: ContextShuntAdapter,
|
|
92
110
|
): Promise<void> {
|
|
93
111
|
await openDelegateEditor(ctx, pi);
|
|
94
112
|
const state = await loadRuntime(ctx);
|
|
95
113
|
setRuntime(state);
|
|
114
|
+
synchronizeContextShunt(shunt, state);
|
|
96
115
|
updateStatus(ctx, state);
|
|
97
116
|
}
|
|
98
117
|
export function statusText(state: RuntimeState): string {
|
|
@@ -233,27 +252,36 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
|
|
|
233
252
|
getArgumentCompletions,
|
|
234
253
|
handler: async (args, ctx) => {
|
|
235
254
|
const action = parseCommand(args);
|
|
236
|
-
if (action.kind === "open") return openEditor(pi, ctx, rememberRuntime);
|
|
255
|
+
if (action.kind === "open") return openEditor(pi, ctx, rememberRuntime, shunt);
|
|
237
256
|
if (action.kind === "status" || action.kind === "context-status") {
|
|
238
257
|
const state = await refreshRuntime(ctx);
|
|
239
|
-
ctx.ui.notify(
|
|
258
|
+
ctx.ui.notify(
|
|
259
|
+
action.kind === "context-status" ? contextStatusText(state, shunt) : statusText(state),
|
|
260
|
+
hasRuntimeError(state) ? "error" : "info",
|
|
261
|
+
);
|
|
240
262
|
return;
|
|
241
263
|
}
|
|
242
264
|
if (action.kind === "reset") {
|
|
243
|
-
rememberRuntime(await resetSession(pi, ctx));
|
|
265
|
+
const state = rememberRuntime(await resetSession(pi, ctx));
|
|
266
|
+
synchronizeContextShunt(shunt, state);
|
|
244
267
|
return;
|
|
245
268
|
}
|
|
246
269
|
if (action.kind === "intensity") {
|
|
247
|
-
rememberRuntime(await setSessionIntensity(pi, ctx, action.intensity));
|
|
270
|
+
const state = rememberRuntime(await setSessionIntensity(pi, ctx, action.intensity));
|
|
271
|
+
synchronizeContextShunt(shunt, state);
|
|
248
272
|
return;
|
|
249
273
|
}
|
|
250
274
|
if (action.kind === "context-mode") {
|
|
251
|
-
rememberRuntime(await setContextMode(pi, ctx, action.mode));
|
|
275
|
+
const state = rememberRuntime(await setContextMode(pi, ctx, action.mode));
|
|
276
|
+
synchronizeContextShunt(shunt, state);
|
|
252
277
|
return;
|
|
253
278
|
}
|
|
254
279
|
if (action.kind === "context-allow") {
|
|
255
280
|
const state = await refreshRuntime(ctx);
|
|
256
|
-
|
|
281
|
+
synchronizeContextShunt(shunt, state);
|
|
282
|
+
const allowed =
|
|
283
|
+
!isContextShuntDisabled(state) &&
|
|
284
|
+
shunt.allowPending(action.token, action.maxLines, action.maxBytes);
|
|
257
285
|
ctx.ui.notify(
|
|
258
286
|
allowed
|
|
259
287
|
? "One-time ContextShunt exception is ready for the matching next call."
|
|
@@ -271,12 +299,13 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
|
|
|
271
299
|
});
|
|
272
300
|
pi.registerShortcut("alt+g", {
|
|
273
301
|
description: "Open delegation policy",
|
|
274
|
-
handler: async (ctx) => openEditor(pi, ctx, rememberRuntime),
|
|
302
|
+
handler: async (ctx) => openEditor(pi, ctx, rememberRuntime, shunt),
|
|
275
303
|
});
|
|
276
304
|
pi.on("session_start", async (_event, ctx) => {
|
|
277
305
|
await refreshRuntime(ctx);
|
|
278
306
|
});
|
|
279
307
|
pi.on("session_tree", async (_event, ctx) => {
|
|
308
|
+
shunt.clearPending();
|
|
280
309
|
await refreshRuntime(ctx);
|
|
281
310
|
});
|
|
282
311
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
@@ -284,14 +313,23 @@ export default function piDelegationPolicy(pi: ExtensionAPI): void {
|
|
|
284
313
|
const policy = buildDelegationPolicy(state);
|
|
285
314
|
return policy ? { systemPrompt: `${event.systemPrompt}\n\n${policy}` } : undefined;
|
|
286
315
|
});
|
|
316
|
+
pi.on("agent_end", async () => {
|
|
317
|
+
shunt.clearConsumed();
|
|
318
|
+
});
|
|
287
319
|
pi.on("tool_call", async (event) => {
|
|
288
320
|
const settings = latestRuntime?.effective.contextShunt;
|
|
289
|
-
if (!settings || settings.mode === "off")
|
|
321
|
+
if (!settings || settings.mode === "off") {
|
|
322
|
+
shunt.clearPending();
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
290
325
|
return shunt.onToolCall(event, settings, isBuiltinTool(pi, event.toolName));
|
|
291
326
|
});
|
|
292
327
|
pi.on("tool_result", async (event, ctx) => {
|
|
293
328
|
const settings = latestRuntime?.effective.contextShunt;
|
|
294
|
-
if (!settings || settings.mode === "off")
|
|
329
|
+
if (!settings || settings.mode === "off") {
|
|
330
|
+
shunt.clearPending();
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
295
333
|
return shunt.onToolResult(event, settings, ctx.signal, isBuiltinTool(pi, event.toolName));
|
|
296
334
|
});
|
|
297
335
|
pi.on("session_shutdown", async (_event, ctx) => {
|