@duke-dsh-plugins/dsh-agent-approval 1.4.2 → 1.6.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 +9 -7
- package/client.js +324 -106
- package/index.js +544 -81
- package/package.json +2 -2
- package/typert.host.js +121 -19
package/index.js
CHANGED
|
@@ -31,15 +31,28 @@
|
|
|
31
31
|
* `callId`) plus the asker's stated reason. A rejection must name the
|
|
32
32
|
* concrete, credible risk the operation creates (destructive /
|
|
33
33
|
* irreversible / out-of-scope / dishonest); vague unease is approved.
|
|
34
|
+
* v1.6.0: the judge can alternatively be the TypeSafe Jev "System One"
|
|
35
|
+
* decision model (synthetic provider id `typesafe`) — a direct HTTP
|
|
36
|
+
* call that answers typed Choice/Noul questions with calibrated
|
|
37
|
+
* probabilities; a confidence below the configured gate resolves
|
|
38
|
+
* fail-closed like any other fault (see `_judgeWithJev`).
|
|
34
39
|
*
|
|
35
40
|
* 3. FAIL CLOSED — any infrastructure fault, timeout, malformed verdict, or
|
|
36
41
|
* cancellation maps to the fail-closed approval outcomes
|
|
37
42
|
* (`unavailable` / `cancelled`), never to a grant.
|
|
38
43
|
*
|
|
39
|
-
* 4. AUDIT — every decision is
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the session
|
|
44
|
+
* 4. AUDIT — every decision is appended to a SIDECAR file inside the
|
|
45
|
+
* requesting session's OWN persistence directory
|
|
46
|
+
* (`<sessionDir>/agent-approval.jsonl`, resolved via
|
|
47
|
+
* `sessionPersistence.locate`), so the audit trail follows the session
|
|
48
|
+
* exactly: it survives restarts with the session, disappears when the
|
|
49
|
+
* session is deleted, and NEVER touches the durable event log — no
|
|
50
|
+
* custom events written, none read (the log's strict event-type
|
|
51
|
+
* vocabulary makes plugin-defined types unsafe, and per project ruling
|
|
52
|
+
* session.jsonl.zstd carries zero plugin data). The conversation
|
|
53
|
+
* window's「审批」tab (next to 轨迹) folds those records per session;
|
|
54
|
+
* the judge's own child session id is kept so the full reasoning trail
|
|
55
|
+
* can be inspected in the session list.
|
|
43
56
|
*
|
|
44
57
|
* Mount on the HOST plane (profile `cordis.patch.yml` insert row): the
|
|
45
58
|
* approval waterfall listener must be unscoped to see every live agent, and
|
|
@@ -50,7 +63,7 @@ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
|
50
63
|
import { Service } from "@deepseek-ai/cordis";
|
|
51
64
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
52
65
|
import { homedir } from "node:os";
|
|
53
|
-
import { join } from "node:path";
|
|
66
|
+
import { dirname, join } from "node:path";
|
|
54
67
|
|
|
55
68
|
// ---- constants --------------------------------------------------------------
|
|
56
69
|
|
|
@@ -66,18 +79,90 @@ const PRESET_NAME = "agent-approval";
|
|
|
66
79
|
const DEFAULT_TIMEOUT_MS = 120000;
|
|
67
80
|
const MIN_TIMEOUT_MS = 30000;
|
|
68
81
|
const MAX_TIMEOUT_MS = 600000;
|
|
69
|
-
/** In-memory audit ring size (the Settings page shows the latest 50). */
|
|
70
|
-
const MAX_RECORDS = 200;
|
|
71
82
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
83
|
+
* v1.5.1, tightened in v1.5.2: audit records live in a SIDECAR FILE inside
|
|
84
|
+
* the session's OWN persistence directory (`<sessionDir>/agent-approval.jsonl`,
|
|
85
|
+
* resolved via `sessionPersistence.locate(header)`), so they still follow the
|
|
86
|
+
* session exactly — restored/kept with it, gone when the session directory is
|
|
87
|
+
* deleted. The durable event log (session.jsonl.zstd) is NEVER read for
|
|
88
|
+
* records and NEVER written by this plugin: writing custom event types into
|
|
89
|
+
* the log (v1.5.0's approach) is NOT viable — the persistence read path
|
|
90
|
+
* refuses a whole log containing an event type outside
|
|
91
|
+
* `KNOWN_SESSION_EVENT_TYPES` unless the envelope carries `ignorable: true`,
|
|
92
|
+
* and the live-session writer `session.append()` cannot set that marker —
|
|
93
|
+
* the first judged escalation made the session unresumable (2026-09-06, two
|
|
94
|
+
* poisoned log events repaired in place). Per the final ruling: the log
|
|
95
|
+
* carries ZERO plugin-defined data, and the audit tab reads the sidecar
|
|
96
|
+
* only. A handful of ignorable-marked v1.5.0-era record events remain in one
|
|
97
|
+
* historical log as inert, load-verified history; physically deleting them
|
|
98
|
+
* would require whole-log seq renumbering and is not worth the corruption
|
|
99
|
+
* risk.
|
|
100
|
+
*/
|
|
101
|
+
/** The sidecar file name inside a session's persistence directory. */
|
|
102
|
+
const RECORDS_SIDECAR = "agent-approval.jsonl";
|
|
103
|
+
/**
|
|
104
|
+
* On-disk persistence for the judge settings (model override + timeout +
|
|
105
|
+
* rules). Lives under DSH_HOME (same resolution as the plugin's own README
|
|
106
|
+
* documents), outside any profile's node_modules so reinstalls and upgrades
|
|
107
|
+
* never touch it.
|
|
76
108
|
*/
|
|
77
109
|
const DATA_DIR = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "agent-approval");
|
|
78
|
-
const RECORDS_FILE = join(DATA_DIR, "records.jsonl");
|
|
79
110
|
const CONFIG_FILE = join(DATA_DIR, "config.json");
|
|
80
111
|
|
|
112
|
+
/**
|
|
113
|
+
* v1.6.0: the TypeSafe Jev judge backend. Jev is a "System One" decision
|
|
114
|
+
* model (https://api.typesafe.ai/v1/systemone): it does not generate text —
|
|
115
|
+
* it answers typed questions (Choice / Score / Noul) over one `state` with
|
|
116
|
+
* calibrated probability distributions in ~70–500ms. That is exactly the
|
|
117
|
+
* approval-verdict shape, so instead of spawning a judge subagent through
|
|
118
|
+
* the harness model registry (Jev is not a chat route and cannot appear in
|
|
119
|
+
* `llm.listProviders()`), the Host half calls its HTTP API directly when the
|
|
120
|
+
* configured judge provider is the synthetic `typesafe` id. Fail-closed is
|
|
121
|
+
* preserved end to end: any transport fault, non-200, malformed answer, or a
|
|
122
|
+
* confidence below the configured gate resolves `unavailable` — never a
|
|
123
|
+
* grant, and (below the gate) not a recorded rejection either.
|
|
124
|
+
*/
|
|
125
|
+
const JEV_PROVIDER = "typesafe";
|
|
126
|
+
const JEV_DEFAULT_MODEL = "jev-latest";
|
|
127
|
+
const JEV_DEFAULT_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
128
|
+
const JEV_DEFAULT_CONFIDENCE = 0.5;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The typed questions sent to Jev. Policy lives in the criteria descriptions
|
|
132
|
+
* (Jev reads instructions literally and injects domain knowledge only through
|
|
133
|
+
* state + criteria); the wording mirrors the subagent judge prompt's APPROVE
|
|
134
|
+
* conditions / REJECT list / 误杀治理 so both backends judge to the same
|
|
135
|
+
* standard. `decision` and `riskLevel` are Choices (discrete options +
|
|
136
|
+
* probabilities + confidence); `concreteRisk` is a Noul probe whose
|
|
137
|
+
* probability is folded into the synthesized audit rationale.
|
|
138
|
+
*/
|
|
139
|
+
const JEV_QUESTIONS = {
|
|
140
|
+
decision: {
|
|
141
|
+
type: "choice",
|
|
142
|
+
instructions: "Should this one-time sandbox-escalation request be approved?",
|
|
143
|
+
criteria: {
|
|
144
|
+
approve:
|
|
145
|
+
"Yes, approve once. The operation is plausibly safe, non-destructive and reversible; it is clearly required by or consistent with the user's task; the stated reason honestly matches the actual arguments; and granting it once cannot leak secrets or cause irreversible system-wide changes. The project's own documented install/build/deploy steps that place the project's own files into the tool's own documented directories, and reading tool-owned config or logs needed for the task, count as task-scoped.",
|
|
146
|
+
reject:
|
|
147
|
+
"No, reject. The operation is destructive or irreversible (mass deletion, disk formatting, registry/service/system-wide changes), exfiltrates credentials or secrets, touches resources unrelated to the task, modifies the operating system or other applications' data, hides intent behind encoded or obfuscated content, the stated reason does not match the actual arguments, or there is a concrete, credible risk — something it would destroy, leak, or change beyond the user's task.",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
riskLevel: {
|
|
151
|
+
type: "choice",
|
|
152
|
+
instructions: "How risky is the requested operation?",
|
|
153
|
+
criteria: {
|
|
154
|
+
low: "Routine and easily reversible: reading files, or writing within the project workspace that can be regenerated or undone.",
|
|
155
|
+
medium: "Awkward to undo or touches more than the immediate task outputs, but not destructive and not security-sensitive.",
|
|
156
|
+
high: "Destructive, irreversible, system-wide, or touching credentials, secrets, or other applications' data.",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
concreteRisk: {
|
|
160
|
+
type: "noul",
|
|
161
|
+
instructions:
|
|
162
|
+
"Does this specific operation create a concrete, credible risk — destroying data, leaking secrets or credentials, or changing the operating system, other applications, or resources beyond the user's task? Answer false when the operation is task-scoped and reversible; vague unease or an unfamiliar command is NOT a risk.",
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
81
166
|
/**
|
|
82
167
|
* The structured verdict the judge subagent MUST produce. Constrained to the
|
|
83
168
|
* JSON-Schema subset `assertObjectJsonSchema` enforces for subagent outputs
|
|
@@ -199,21 +284,32 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
199
284
|
async [Service.init]() {
|
|
200
285
|
markRemoteMethod(this, "getState", "getState");
|
|
201
286
|
markRemoteMethod(this, "setModel", "setModel");
|
|
287
|
+
markRemoteMethod(this, "setJevConfig", "setJevConfig");
|
|
202
288
|
markRemoteMethod(this, "setApprovalTimeout", "setApprovalTimeout");
|
|
203
289
|
markRemoteMethod(this, "toggle", "toggle");
|
|
204
290
|
markRemoteMethod(this, "addRule", "addRule");
|
|
205
291
|
markRemoteMethod(this, "removeRule", "removeRule");
|
|
206
|
-
markRemoteMethod(this, "
|
|
292
|
+
markRemoteMethod(this, "sessionRecords", "sessionRecords");
|
|
207
293
|
markRemoteMethod(this, "directory", "directory");
|
|
208
294
|
|
|
209
295
|
/** Judge model override; empty strings = use the harness default route. */
|
|
210
296
|
this._model = { provider: "", model: "" };
|
|
297
|
+
/**
|
|
298
|
+
* TypeSafe Jev direct backend settings (used when `_model.provider` is
|
|
299
|
+
* the synthetic `typesafe` id). The API key lives in plaintext on this
|
|
300
|
+
* machine only (config.json, same trust domain as the rest of the
|
|
301
|
+
* settings); an empty key falls back to the TYPESAFE_API_KEY env var.
|
|
302
|
+
*/
|
|
303
|
+
this._jev = {
|
|
304
|
+
apiKey: "",
|
|
305
|
+
endpoint: JEV_DEFAULT_ENDPOINT,
|
|
306
|
+
model: JEV_DEFAULT_MODEL,
|
|
307
|
+
confidence: JEV_DEFAULT_CONFIDENCE,
|
|
308
|
+
};
|
|
211
309
|
/** Judge timeout in ms (clamped); a timeout resolves fail-closed. */
|
|
212
310
|
this._timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
213
311
|
/** sessionId -> { prevSandbox?: string, prevApproval?: string } */
|
|
214
312
|
this._enabled = new Map();
|
|
215
|
-
/** Audit records, oldest first, capped at MAX_RECORDS. */
|
|
216
|
-
this._records = [];
|
|
217
313
|
/**
|
|
218
314
|
* Deterministic rules judged BEFORE the model (persisted in config.json):
|
|
219
315
|
* [{ id, effect: "allow"|"deny", tool, match, note, createdAt }]. A hit
|
|
@@ -571,11 +667,16 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
571
667
|
|
|
572
668
|
// ---- audit ----------------------------------------------------------------
|
|
573
669
|
|
|
574
|
-
/**
|
|
575
|
-
|
|
670
|
+
/**
|
|
671
|
+
* Coerce one entry to the strict wire shape (typert result schema). The
|
|
672
|
+
* session column is filled by the reader — the sidecar lives inside the
|
|
673
|
+
* session's own directory, so the id is implied but still stamped into
|
|
674
|
+
* every line to keep the file self-describing.
|
|
675
|
+
*/
|
|
676
|
+
_recordShape(sessionId, entry) {
|
|
576
677
|
return {
|
|
577
678
|
at: String(entry.at),
|
|
578
|
-
sessionId: String(
|
|
679
|
+
sessionId: String(sessionId),
|
|
579
680
|
toolName: String(entry.toolName),
|
|
580
681
|
reason: String(entry.reason),
|
|
581
682
|
args: String(entry.args),
|
|
@@ -588,35 +689,83 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
588
689
|
};
|
|
589
690
|
}
|
|
590
691
|
|
|
591
|
-
/**
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
692
|
+
/**
|
|
693
|
+
* Resolve the audit sidecar for one session: `agent-approval.jsonl` inside
|
|
694
|
+
* the session's persistence directory (same directory as the session's own
|
|
695
|
+
* durable log, via `sessionPersistence.locate(header)` — a pure path
|
|
696
|
+
* resolution that also works for live sessions). Falls back to a
|
|
697
|
+
* plugin-owned per-session file under DSH_HOME when the seam or the
|
|
698
|
+
* location is unavailable; the fallback keeps restart-safety at the cost
|
|
699
|
+
* of not being cleaned up when the session is deleted.
|
|
700
|
+
*/
|
|
701
|
+
async _recordsFileOf(session) {
|
|
702
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
703
|
+
if (persistence !== undefined && typeof persistence.locate === "function") {
|
|
704
|
+
try {
|
|
705
|
+
const loc = persistence.locate(session.header);
|
|
706
|
+
if (loc && typeof loc.path === "string" && loc.path !== "") {
|
|
707
|
+
return join(dirname(loc.path), RECORDS_SIDECAR);
|
|
708
|
+
}
|
|
709
|
+
} catch (e) {
|
|
710
|
+
/* fall through to the plugin-owned fallback */
|
|
711
|
+
}
|
|
597
712
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
713
|
+
return join(DATA_DIR, "records", `${String(session.id)}.jsonl`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Append one audit record to the session's SIDECAR file (see
|
|
718
|
+
* `_recordsFileOf`). Appending must never break the approval flow it
|
|
719
|
+
* audits: fire-and-forget with every failure swallowed.
|
|
720
|
+
*/
|
|
721
|
+
_record(session, entry) {
|
|
722
|
+
const shape = this._recordShape(session.id, entry);
|
|
723
|
+
void (async () => {
|
|
724
|
+
try {
|
|
725
|
+
const file = await this._recordsFileOf(session);
|
|
726
|
+
await mkdir(dirname(file), { recursive: true });
|
|
727
|
+
await appendFile(file, JSON.stringify(shape) + "\n", "utf8");
|
|
728
|
+
} catch (e) {
|
|
729
|
+
/* audit is best-effort; the approval outcome still stands */
|
|
730
|
+
}
|
|
731
|
+
})();
|
|
603
732
|
}
|
|
604
733
|
|
|
605
|
-
/**
|
|
606
|
-
|
|
734
|
+
/**
|
|
735
|
+
* Fold one session's audit records (chronological by `at`). The sidecar
|
|
736
|
+
* file is the ONLY source — the durable event log is never consulted
|
|
737
|
+
* (v1.5.2: zero custom data read from or written to session.jsonl.zstd).
|
|
738
|
+
* Never throws.
|
|
739
|
+
*/
|
|
740
|
+
async _recordsOf(session) {
|
|
741
|
+
const out = [];
|
|
607
742
|
try {
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
743
|
+
const file = await this._recordsFileOf(session);
|
|
744
|
+
const text = await readFile(file, "utf8");
|
|
745
|
+
for (const raw of text.split("\n")) {
|
|
746
|
+
const line = raw.trim();
|
|
747
|
+
if (line === "") continue;
|
|
748
|
+
try {
|
|
749
|
+
const parsed = JSON.parse(line);
|
|
750
|
+
if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
|
|
751
|
+
out.push(this._recordShape(session.id, parsed));
|
|
752
|
+
}
|
|
753
|
+
} catch (e) {
|
|
754
|
+
/* skip the corrupt line */
|
|
755
|
+
}
|
|
756
|
+
}
|
|
611
757
|
} catch (e) {
|
|
612
|
-
/*
|
|
758
|
+
/* no sidecar yet */
|
|
613
759
|
}
|
|
760
|
+
out.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
761
|
+
return out;
|
|
614
762
|
}
|
|
615
763
|
|
|
616
764
|
/** Persist the judge settings (model override + timeout + rules) to config.json. */
|
|
617
765
|
_persistConfig() {
|
|
618
766
|
const body = JSON.stringify({
|
|
619
767
|
model: { provider: this._model.provider, model: this._model.model },
|
|
768
|
+
jev: this._jevShape(),
|
|
620
769
|
timeoutMs: this._timeoutMs,
|
|
621
770
|
rules: this._rules,
|
|
622
771
|
});
|
|
@@ -628,9 +777,9 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
628
777
|
}
|
|
629
778
|
|
|
630
779
|
/**
|
|
631
|
-
* Load persisted settings
|
|
632
|
-
*
|
|
633
|
-
*
|
|
780
|
+
* Load persisted judge settings at startup (audit records need no loading —
|
|
781
|
+
* they live in the session logs and are folded per session on demand).
|
|
782
|
+
* Corrupt config is skipped; never throws.
|
|
634
783
|
*/
|
|
635
784
|
async _loadPersisted() {
|
|
636
785
|
try {
|
|
@@ -643,6 +792,18 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
643
792
|
) {
|
|
644
793
|
this._model = { provider: cfg.model.provider, model: cfg.model.model };
|
|
645
794
|
}
|
|
795
|
+
if (cfg.jev && typeof cfg.jev === "object") {
|
|
796
|
+
if (typeof cfg.jev.apiKey === "string") this._jev.apiKey = cfg.jev.apiKey;
|
|
797
|
+
if (typeof cfg.jev.endpoint === "string" && cfg.jev.endpoint !== "") {
|
|
798
|
+
this._jev.endpoint = cfg.jev.endpoint;
|
|
799
|
+
}
|
|
800
|
+
if (typeof cfg.jev.model === "string" && cfg.jev.model !== "") {
|
|
801
|
+
this._jev.model = cfg.jev.model;
|
|
802
|
+
}
|
|
803
|
+
if (typeof cfg.jev.confidence === "number" && Number.isFinite(cfg.jev.confidence)) {
|
|
804
|
+
this._jev.confidence = Math.min(0.99, Math.max(0.01, cfg.jev.confidence));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
646
807
|
if (typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs)) {
|
|
647
808
|
this._timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.floor(cfg.timeoutMs)));
|
|
648
809
|
}
|
|
@@ -671,28 +832,6 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
671
832
|
} catch (e) {
|
|
672
833
|
/* first run or unreadable config — keep the defaults */
|
|
673
834
|
}
|
|
674
|
-
try {
|
|
675
|
-
const text = await readFile(RECORDS_FILE, "utf8");
|
|
676
|
-
const lines = text.split("\n");
|
|
677
|
-
const kept = [];
|
|
678
|
-
for (let i = lines.length - 1; i >= 0 && kept.length < MAX_RECORDS; i--) {
|
|
679
|
-
const line = lines[i].trim();
|
|
680
|
-
if (line === "") continue;
|
|
681
|
-
try {
|
|
682
|
-
const parsed = JSON.parse(line);
|
|
683
|
-
if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
|
|
684
|
-
kept.push(this._recordShape(parsed));
|
|
685
|
-
}
|
|
686
|
-
} catch (e) {
|
|
687
|
-
/* skip the corrupt line */
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
kept.reverse();
|
|
691
|
-
this._records = kept;
|
|
692
|
-
if (lines.length > kept.length) await this._rewriteRecordsFile();
|
|
693
|
-
} catch (e) {
|
|
694
|
-
/* no records file yet */
|
|
695
|
-
}
|
|
696
835
|
}
|
|
697
836
|
|
|
698
837
|
// ---- the claimer ----------------------------------------------------------
|
|
@@ -811,9 +950,8 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
811
950
|
// A listener throw would make the whole waterfall fail closed with
|
|
812
951
|
// 'unavailable' anyway; record what we can and resolve the same way.
|
|
813
952
|
try {
|
|
814
|
-
this._record({
|
|
953
|
+
this._record(session, {
|
|
815
954
|
at: new Date().toISOString(),
|
|
816
|
-
sessionId: shortId(session.id),
|
|
817
955
|
toolName: String(req.toolName),
|
|
818
956
|
reason: trunc(req.reason, 300),
|
|
819
957
|
args: "",
|
|
@@ -839,6 +977,10 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
839
977
|
* records display — "p/m" = selected, "default(p/m)" = harness default.
|
|
840
978
|
*/
|
|
841
979
|
_judgeRoute() {
|
|
980
|
+
if (this._model.provider === JEV_PROVIDER) {
|
|
981
|
+
const model = this._jevEffective().model;
|
|
982
|
+
return { provider: JEV_PROVIDER, model: model, label: "jev(" + model + ")" };
|
|
983
|
+
}
|
|
842
984
|
if (this._model.provider !== "" && this._model.model !== "") {
|
|
843
985
|
return {
|
|
844
986
|
provider: this._model.provider,
|
|
@@ -870,7 +1012,6 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
870
1012
|
const toolName = String(req.toolName);
|
|
871
1013
|
const base = {
|
|
872
1014
|
at: startedAt,
|
|
873
|
-
sessionId: shortId(session.id),
|
|
874
1015
|
toolName: toolName,
|
|
875
1016
|
reason: trunc(req.reason, 300),
|
|
876
1017
|
args: trunc(argsRaw, 2000),
|
|
@@ -888,10 +1029,10 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
888
1029
|
(rule.note !== "" ? " — " + rule.note : "");
|
|
889
1030
|
base.durationMs = Date.now() - t0;
|
|
890
1031
|
if (rule.effect === "deny") {
|
|
891
|
-
this._record({ ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
1032
|
+
this._record(session, { ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
892
1033
|
return "rejected";
|
|
893
1034
|
}
|
|
894
|
-
this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
1035
|
+
this._record(session, { ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
895
1036
|
return "allowed-once";
|
|
896
1037
|
}
|
|
897
1038
|
|
|
@@ -901,11 +1042,17 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
901
1042
|
const trusted = this._trusted.get(session.id);
|
|
902
1043
|
if (trustKey !== undefined && trusted !== undefined && trusted.has(trustKey)) {
|
|
903
1044
|
base.durationMs = Date.now() - t0;
|
|
904
|
-
this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "trust", rationale: "trusted: an identical operation was already approved in this session" });
|
|
1045
|
+
this._record(session, { ...base, outcome: "allowed-once", riskLevel: "-", model: "trust", rationale: "trusted: an identical operation was already approved in this session" });
|
|
905
1046
|
return "allowed-once";
|
|
906
1047
|
}
|
|
907
1048
|
|
|
908
|
-
// 3. The
|
|
1049
|
+
// 3. The judge. The TypeSafe Jev backend is a direct HTTP call (no
|
|
1050
|
+
// subagent, no harness model route); anything else spawns the judge
|
|
1051
|
+
// child through the `spawn` provider as before.
|
|
1052
|
+
if (this._model.provider === JEV_PROVIDER) {
|
|
1053
|
+
return this._judgeWithJev(session, req, argsRaw, base, trustKey);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
909
1056
|
const route = this._judgeRoute();
|
|
910
1057
|
|
|
911
1058
|
let run;
|
|
@@ -923,7 +1070,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
923
1070
|
persona: APPROVER_PERSONA,
|
|
924
1071
|
});
|
|
925
1072
|
} catch (error) {
|
|
926
|
-
this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
|
|
1073
|
+
this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
|
|
927
1074
|
return "unavailable";
|
|
928
1075
|
}
|
|
929
1076
|
base.childSessionId = shortId(run.id);
|
|
@@ -960,7 +1107,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
960
1107
|
(verdict.decision === "approve" || verdict.decision === "reject")
|
|
961
1108
|
) {
|
|
962
1109
|
const approved = verdict.decision === "approve";
|
|
963
|
-
this._record({
|
|
1110
|
+
this._record(session, {
|
|
964
1111
|
...base,
|
|
965
1112
|
outcome: approved ? "allowed-once" : "rejected",
|
|
966
1113
|
riskLevel: String(verdict.riskLevel || "-"),
|
|
@@ -979,7 +1126,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
979
1126
|
}
|
|
980
1127
|
return approved ? "allowed-once" : "rejected";
|
|
981
1128
|
}
|
|
982
|
-
this._record({
|
|
1129
|
+
this._record(session, {
|
|
983
1130
|
...base,
|
|
984
1131
|
outcome: "unavailable",
|
|
985
1132
|
riskLevel: "-",
|
|
@@ -990,11 +1137,11 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
990
1137
|
return "unavailable";
|
|
991
1138
|
}
|
|
992
1139
|
if (winner.kind === "aborted") {
|
|
993
|
-
this._record({ ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
|
|
1140
|
+
this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
|
|
994
1141
|
return "cancelled";
|
|
995
1142
|
}
|
|
996
1143
|
if (winner.kind === "timeout") {
|
|
997
|
-
this._record({
|
|
1144
|
+
this._record(session, {
|
|
998
1145
|
...base,
|
|
999
1146
|
outcome: "unavailable",
|
|
1000
1147
|
riskLevel: "-",
|
|
@@ -1003,10 +1150,280 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1003
1150
|
});
|
|
1004
1151
|
return "unavailable";
|
|
1005
1152
|
}
|
|
1006
|
-
this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
|
|
1153
|
+
this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
|
|
1154
|
+
return "unavailable";
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ---- the TypeSafe Jev direct backend ---------------------------------------
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Effective Jev settings with env fallback and clamping applied. The key
|
|
1161
|
+
* may come from config.json or the TYPESAFE_API_KEY environment variable;
|
|
1162
|
+
* an absent key keeps the backend selected but every judgment resolves
|
|
1163
|
+
* `unavailable` (fail closed) until one is configured.
|
|
1164
|
+
*/
|
|
1165
|
+
_jevEffective() {
|
|
1166
|
+
const key = String(this._jev.apiKey || process.env.TYPESAFE_API_KEY || "").trim();
|
|
1167
|
+
const endpoint = String(this._jev.endpoint || "").trim() || JEV_DEFAULT_ENDPOINT;
|
|
1168
|
+
const model = String(this._jev.model || "").trim() || JEV_DEFAULT_MODEL;
|
|
1169
|
+
let confidence = Number(this._jev.confidence);
|
|
1170
|
+
if (!Number.isFinite(confidence)) confidence = JEV_DEFAULT_CONFIDENCE;
|
|
1171
|
+
confidence = Math.min(0.99, Math.max(0.01, confidence));
|
|
1172
|
+
return { key: key, endpoint: endpoint, model: model, confidence: confidence };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Owned plain copy of the Jev settings for the wire and config.json. */
|
|
1176
|
+
_jevShape() {
|
|
1177
|
+
return {
|
|
1178
|
+
apiKey: String(this._jev.apiKey || ""),
|
|
1179
|
+
endpoint: String(this._jev.endpoint || JEV_DEFAULT_ENDPOINT),
|
|
1180
|
+
model: String(this._jev.model || JEV_DEFAULT_MODEL),
|
|
1181
|
+
confidence: Number(this._jev.confidence) || JEV_DEFAULT_CONFIDENCE,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* The `state` sent to Jev: the same ground truth the subagent judge sees
|
|
1187
|
+
* (workspace, exact arguments, stated reason, first + recent genuine user
|
|
1188
|
+
* messages), as a named object — the shape TypeSafe recommends. All values
|
|
1189
|
+
* are pre-truncated strings so the 32K state budget is respected.
|
|
1190
|
+
*/
|
|
1191
|
+
_jevStateOf(session, req, argsRaw) {
|
|
1192
|
+
let cwd = "";
|
|
1193
|
+
try {
|
|
1194
|
+
if (session.header && typeof session.header.cwd === "string") cwd = session.header.cwd;
|
|
1195
|
+
} catch (e) {
|
|
1196
|
+
/* header access is best-effort */
|
|
1197
|
+
}
|
|
1198
|
+
const task = this._recentUserContext(session);
|
|
1199
|
+
return {
|
|
1200
|
+
workspace: cwd || "(unknown)",
|
|
1201
|
+
tool: String(req.toolName),
|
|
1202
|
+
statedReason:
|
|
1203
|
+
typeof req.reason === "string" && req.reason !== "" ? trunc(req.reason, 300) : "(none)",
|
|
1204
|
+
toolArguments: argsRaw === undefined ? "(not available)" : trunc(argsRaw, 4000) || "(empty)",
|
|
1205
|
+
firstUserMessage: task.first !== "" ? task.first : "(no user messages available)",
|
|
1206
|
+
recentUserMessages: task.recent,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Judge one escalation through the Jev HTTP API (state + typed questions →
|
|
1212
|
+
* calibrated probability distributions). Mirrors `_judge`'s spawn-path
|
|
1213
|
+
* contract exactly — rules and the session trust cache have already run —
|
|
1214
|
+
* and every abnormal shape resolves fail-closed:
|
|
1215
|
+
* - no API key / transport fault / non-200 / malformed answer → `unavailable`
|
|
1216
|
+
* - request cancelled mid-flight → `cancelled`
|
|
1217
|
+
* - overall timeout (the same `this._timeoutMs` budget) → `unavailable`
|
|
1218
|
+
* - confidence below the configured gate → `unavailable` (the model is
|
|
1219
|
+
* not sure enough to decide: never a grant, and not a recorded
|
|
1220
|
+
* rejection either — the v1.4.0 误杀治理 applies symmetrically)
|
|
1221
|
+
* Jev does not generate text, so the audit rationale is synthesized from
|
|
1222
|
+
* the returned distributions; the served model version (`body.model`,
|
|
1223
|
+
* which resolves aliases like jev-latest) is what the audit displays.
|
|
1224
|
+
*/
|
|
1225
|
+
async _judgeWithJev(session, req, argsRaw, base, trustKey) {
|
|
1226
|
+
const cfg = this._jevEffective();
|
|
1227
|
+
if (cfg.key === "") {
|
|
1228
|
+
this._record(session, {
|
|
1229
|
+
...base,
|
|
1230
|
+
outcome: "unavailable",
|
|
1231
|
+
riskLevel: "-",
|
|
1232
|
+
model: "jev(" + cfg.model + ")",
|
|
1233
|
+
rationale: "Jev backend selected but no API key configured (Settings → Agent 审批, or the TYPESAFE_API_KEY environment variable)",
|
|
1234
|
+
});
|
|
1235
|
+
return "unavailable";
|
|
1236
|
+
}
|
|
1237
|
+
if (typeof fetch !== "function") {
|
|
1238
|
+
this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "fetch is unavailable in this runtime" });
|
|
1239
|
+
return "unavailable";
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const startedAt = Date.now();
|
|
1243
|
+
const controller = new AbortController();
|
|
1244
|
+
const signal = req.signal;
|
|
1245
|
+
const onAbort = () => controller.abort();
|
|
1246
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
1247
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
let winner;
|
|
1251
|
+
try {
|
|
1252
|
+
const state = this._jevStateOf(session, req, argsRaw);
|
|
1253
|
+
winner = await Promise.race([
|
|
1254
|
+
this._jevRequest(cfg, state, controller.signal)
|
|
1255
|
+
.then((body) => ({ kind: "result", body: body }))
|
|
1256
|
+
.catch((error) => ({
|
|
1257
|
+
kind: "fault",
|
|
1258
|
+
error: error,
|
|
1259
|
+
aborted: error && error.name === "AbortError",
|
|
1260
|
+
})),
|
|
1261
|
+
(signal
|
|
1262
|
+
? new Promise((resolve) => {
|
|
1263
|
+
if (signal.aborted) {
|
|
1264
|
+
resolve(true);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
signal.addEventListener("abort", () => resolve(true), { once: true });
|
|
1268
|
+
})
|
|
1269
|
+
: Promise.resolve(false)
|
|
1270
|
+
).then((v) => ({ kind: "aborted", aborted: v })),
|
|
1271
|
+
this.ctx.timeout(this._timeoutMs).then(() => ({ kind: "timeout" })),
|
|
1272
|
+
]);
|
|
1273
|
+
} finally {
|
|
1274
|
+
if (signal && typeof signal.removeEventListener === "function") {
|
|
1275
|
+
signal.removeEventListener("abort", onAbort);
|
|
1276
|
+
}
|
|
1277
|
+
// Whether we lost the race to timeout/cancel or the request already
|
|
1278
|
+
// settled, closing the transport is always safe.
|
|
1279
|
+
try {
|
|
1280
|
+
controller.abort();
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
/* controller abort never blocks the outcome */
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
const durationMs = Date.now() - startedAt;
|
|
1286
|
+
|
|
1287
|
+
if (winner.kind === "result") {
|
|
1288
|
+
return this._jevVerdict(session, winner.body, cfg, base, trustKey, durationMs);
|
|
1289
|
+
}
|
|
1290
|
+
if (winner.kind === "aborted") {
|
|
1291
|
+
this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "request cancelled while Jev was judging" });
|
|
1292
|
+
return "cancelled";
|
|
1293
|
+
}
|
|
1294
|
+
if (winner.kind === "timeout") {
|
|
1295
|
+
this._record(session, {
|
|
1296
|
+
...base,
|
|
1297
|
+
outcome: "unavailable",
|
|
1298
|
+
riskLevel: "-",
|
|
1299
|
+
model: "jev(" + cfg.model + ")",
|
|
1300
|
+
rationale: "Jev request timed out after " + String(this._timeoutMs) + "ms (fail closed)",
|
|
1301
|
+
});
|
|
1302
|
+
return "unavailable";
|
|
1303
|
+
}
|
|
1304
|
+
if (winner.aborted) {
|
|
1305
|
+
this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "request cancelled while Jev was judging" });
|
|
1306
|
+
return "cancelled";
|
|
1307
|
+
}
|
|
1308
|
+
this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: "jev(" + cfg.model + ")", rationale: "Jev request failed: " + errText(winner.error) });
|
|
1007
1309
|
return "unavailable";
|
|
1008
1310
|
}
|
|
1009
1311
|
|
|
1312
|
+
/** The single POST to the System One endpoint; resolves the parsed body. */
|
|
1313
|
+
async _jevRequest(cfg, state, abortSignal) {
|
|
1314
|
+
const response = await fetch(cfg.endpoint, {
|
|
1315
|
+
method: "POST",
|
|
1316
|
+
headers: {
|
|
1317
|
+
"Authorization": "Bearer " + cfg.key,
|
|
1318
|
+
"Content-Type": "application/json",
|
|
1319
|
+
},
|
|
1320
|
+
body: JSON.stringify({
|
|
1321
|
+
state: state,
|
|
1322
|
+
model: cfg.model,
|
|
1323
|
+
questions: JEV_QUESTIONS,
|
|
1324
|
+
}),
|
|
1325
|
+
signal: abortSignal,
|
|
1326
|
+
});
|
|
1327
|
+
if (!response.ok) {
|
|
1328
|
+
let detail = "";
|
|
1329
|
+
try {
|
|
1330
|
+
detail = trunc(String(await response.text()), 200);
|
|
1331
|
+
} catch (e) {
|
|
1332
|
+
/* body read is best-effort */
|
|
1333
|
+
}
|
|
1334
|
+
throw new Error("HTTP " + String(response.status) + (detail !== "" ? " " + detail : ""));
|
|
1335
|
+
}
|
|
1336
|
+
const body = await response.json();
|
|
1337
|
+
if (!body || typeof body !== "object") throw new Error("response body is not an object");
|
|
1338
|
+
return body;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Map a Jev response to the same outcomes the subagent path produces.
|
|
1343
|
+
* Returns the waterfall outcome string; records the audit line itself.
|
|
1344
|
+
*/
|
|
1345
|
+
_jevVerdict(session, body, cfg, base, trustKey, durationMs) {
|
|
1346
|
+
const served = typeof body.model === "string" && body.model !== "" ? body.model : cfg.model;
|
|
1347
|
+
const label = "jev(" + served + ")";
|
|
1348
|
+
const answers = body.answers && typeof body.answers === "object" ? body.answers : {};
|
|
1349
|
+
const decision = answers.decision && typeof answers.decision === "object" ? answers.decision : undefined;
|
|
1350
|
+
const risk = answers.riskLevel && typeof answers.riskLevel === "object" ? answers.riskLevel : undefined;
|
|
1351
|
+
const probe = answers.concreteRisk && typeof answers.concreteRisk === "object" ? answers.concreteRisk : undefined;
|
|
1352
|
+
|
|
1353
|
+
const choice = decision && (decision.choice === "approve" || decision.choice === "reject") ? decision.choice : undefined;
|
|
1354
|
+
const confidence = decision ? Number(decision.confidence) : NaN;
|
|
1355
|
+
const probabilities = decision && decision.probabilities && typeof decision.probabilities === "object" ? decision.probabilities : {};
|
|
1356
|
+
const riskChoice =
|
|
1357
|
+
risk && (risk.choice === "low" || risk.choice === "medium" || risk.choice === "high") ? risk.choice : undefined;
|
|
1358
|
+
const probeNoul = probe ? Number(probe.noul) : NaN;
|
|
1359
|
+
|
|
1360
|
+
// Any missing or out-of-shape answer is fail-closed, not guessed.
|
|
1361
|
+
if (
|
|
1362
|
+
choice === undefined ||
|
|
1363
|
+
!Number.isFinite(confidence) ||
|
|
1364
|
+
confidence < 0 ||
|
|
1365
|
+
confidence > 1 ||
|
|
1366
|
+
riskChoice === undefined ||
|
|
1367
|
+
!Number.isFinite(probeNoul)
|
|
1368
|
+
) {
|
|
1369
|
+
this._record(session, {
|
|
1370
|
+
...base,
|
|
1371
|
+
durationMs: durationMs,
|
|
1372
|
+
outcome: "unavailable",
|
|
1373
|
+
riskLevel: "-",
|
|
1374
|
+
model: label,
|
|
1375
|
+
rationale: "Jev returned no valid verdict shape (decision/riskLevel/concreteRisk incomplete)",
|
|
1376
|
+
});
|
|
1377
|
+
return "unavailable";
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Confidence gate: below the threshold the model is not sure enough to
|
|
1381
|
+
// decide at all — never a grant, never a recorded rejection.
|
|
1382
|
+
if (confidence < cfg.confidence) {
|
|
1383
|
+
this._record(session, {
|
|
1384
|
+
...base,
|
|
1385
|
+
durationMs: durationMs,
|
|
1386
|
+
outcome: "unavailable",
|
|
1387
|
+
riskLevel: riskChoice,
|
|
1388
|
+
model: label,
|
|
1389
|
+
rationale:
|
|
1390
|
+
"Jev confidence " + confidence.toFixed(2) + " is below the gate " + cfg.confidence.toFixed(2) + " (decision draft: " + choice + ") — fail closed",
|
|
1391
|
+
});
|
|
1392
|
+
return "unavailable";
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const pApprove = Number(probabilities.approve);
|
|
1396
|
+
const pReject = Number(probabilities.reject);
|
|
1397
|
+
const rationale =
|
|
1398
|
+
"Jev 决策=" + choice +
|
|
1399
|
+
"(置信度 " + confidence.toFixed(2) +
|
|
1400
|
+
(Number.isFinite(pApprove) && Number.isFinite(pReject)
|
|
1401
|
+
? ",p approve/reject " + pApprove.toFixed(2) + "/" + pReject.toFixed(2)
|
|
1402
|
+
: "") +
|
|
1403
|
+
");风险=" + riskChoice +
|
|
1404
|
+
";具体风险概率=" + probeNoul.toFixed(2) +
|
|
1405
|
+
"。Jev 为结构化决策模型,不生成文字,本理由由概率分布合成。";
|
|
1406
|
+
|
|
1407
|
+
base.durationMs = durationMs;
|
|
1408
|
+
const approved = choice === "approve";
|
|
1409
|
+
this._record(session, {
|
|
1410
|
+
...base,
|
|
1411
|
+
outcome: approved ? "allowed-once" : "rejected",
|
|
1412
|
+
riskLevel: riskChoice,
|
|
1413
|
+
model: label,
|
|
1414
|
+
rationale: trunc(rationale, 600),
|
|
1415
|
+
});
|
|
1416
|
+
if (approved && trustKey !== undefined) {
|
|
1417
|
+
let set = this._trusted.get(session.id);
|
|
1418
|
+
if (set === undefined) {
|
|
1419
|
+
set = new Set();
|
|
1420
|
+
this._trusted.set(session.id, set);
|
|
1421
|
+
}
|
|
1422
|
+
set.add(trustKey);
|
|
1423
|
+
}
|
|
1424
|
+
return approved ? "allowed-once" : "rejected";
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1010
1427
|
// ---- Remote API ------------------------------------------------------------
|
|
1011
1428
|
|
|
1012
1429
|
/**
|
|
@@ -1048,10 +1465,10 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1048
1465
|
ok: true,
|
|
1049
1466
|
value: {
|
|
1050
1467
|
model: { provider: this._model.provider, model: this._model.model },
|
|
1468
|
+
jev: this._jevShape(),
|
|
1051
1469
|
timeoutMs: this._timeoutMs,
|
|
1052
1470
|
enabledSessions: this._sessionInfos(),
|
|
1053
1471
|
rules: this._rulesSnapshot(),
|
|
1054
|
-
records: this._records.slice(-50).reverse(),
|
|
1055
1472
|
},
|
|
1056
1473
|
};
|
|
1057
1474
|
}
|
|
@@ -1063,12 +1480,35 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1063
1480
|
async setModel(request) {
|
|
1064
1481
|
const provider = request && typeof request.provider === "string" ? request.provider : "";
|
|
1065
1482
|
const model = request && typeof request.model === "string" ? request.model : "";
|
|
1066
|
-
|
|
1067
|
-
|
|
1483
|
+
if (provider === JEV_PROVIDER) {
|
|
1484
|
+
// The Jev backend ignores the harness route table; an unset model just
|
|
1485
|
+
// means the latest alias.
|
|
1486
|
+
this._model = { provider: JEV_PROVIDER, model: model !== "" ? model : JEV_DEFAULT_MODEL };
|
|
1487
|
+
} else {
|
|
1488
|
+
this._model =
|
|
1489
|
+
provider !== "" && model !== "" ? { provider, model } : { provider: "", model: "" };
|
|
1490
|
+
}
|
|
1068
1491
|
this._persistConfig();
|
|
1069
1492
|
return { ok: true, value: { model: { provider: this._model.provider, model: this._model.model } } };
|
|
1070
1493
|
}
|
|
1071
1494
|
|
|
1495
|
+
/**
|
|
1496
|
+
* Set the TypeSafe Jev backend settings (only provided fields change).
|
|
1497
|
+
* `confidence` is the gate below which Jev's answer is not trusted and the
|
|
1498
|
+
* outcome resolves fail-closed; clamped to [0.01, 0.99]. Persisted.
|
|
1499
|
+
*/
|
|
1500
|
+
async setJevConfig(request) {
|
|
1501
|
+
const r = request && typeof request === "object" ? request : {};
|
|
1502
|
+
if (typeof r.apiKey === "string") this._jev.apiKey = r.apiKey.trim();
|
|
1503
|
+
if (typeof r.endpoint === "string") this._jev.endpoint = r.endpoint.trim();
|
|
1504
|
+
if (typeof r.model === "string") this._jev.model = r.model.trim();
|
|
1505
|
+
if (typeof r.confidence === "number" && Number.isFinite(r.confidence)) {
|
|
1506
|
+
this._jev.confidence = Math.min(0.99, Math.max(0.01, r.confidence));
|
|
1507
|
+
}
|
|
1508
|
+
this._persistConfig();
|
|
1509
|
+
return { ok: true, value: { jev: this._jevShape() } };
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1072
1512
|
/** Set the judge timeout (clamped to [MIN, MAX] milliseconds). Persisted. */
|
|
1073
1513
|
async setApprovalTimeout(request) {
|
|
1074
1514
|
const raw = request && typeof request.timeoutMs === "number" ? request.timeoutMs : 0;
|
|
@@ -1136,11 +1576,34 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1136
1576
|
return { ok: true, value: { rules: this._rulesSnapshot() } };
|
|
1137
1577
|
}
|
|
1138
1578
|
|
|
1139
|
-
/**
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1579
|
+
/**
|
|
1580
|
+
* Fold ONE session's audit records out of its sidecar storage (see
|
|
1581
|
+
* `_recordsFileOf` / `_recordsOf`). Powers the conversation window's「审批」
|
|
1582
|
+
* tab — the records are requested per session and rendered next to the
|
|
1583
|
+
* 轨迹 tab, exactly where they were produced. The session must be live (it
|
|
1584
|
+
* always is when its conversation window is open). Also reports whether
|
|
1585
|
+
* the mode is currently enabled for the session so the tab can show the
|
|
1586
|
+
* state.
|
|
1587
|
+
*/
|
|
1588
|
+
async sessionRecords(request) {
|
|
1589
|
+
const sessionId = request && typeof request.sessionId === "string" ? request.sessionId : "";
|
|
1590
|
+
if (sessionId === "") {
|
|
1591
|
+
return { ok: false, error: { code: "invalid-session", message: "sessionId is required" } };
|
|
1592
|
+
}
|
|
1593
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
1594
|
+
if (agent === undefined) {
|
|
1595
|
+
return {
|
|
1596
|
+
ok: false,
|
|
1597
|
+
error: { code: "session-not-live", message: "that session is not live right now" },
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
return {
|
|
1601
|
+
ok: true,
|
|
1602
|
+
value: {
|
|
1603
|
+
records: await this._recordsOf(agent.session),
|
|
1604
|
+
enabled: this._enabled.has(sessionId),
|
|
1605
|
+
},
|
|
1606
|
+
};
|
|
1144
1607
|
}
|
|
1145
1608
|
|
|
1146
1609
|
/**
|