@phuetz/code-buddy 1.0.0 → 1.1.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 +3 -2
- package/dist/agent/codebuddy-agent.js +33 -0
- package/dist/agent/lesson-auto-proposer.js +10 -0
- package/dist/agent/middleware/session-duration.d.ts +36 -0
- package/dist/agent/middleware/session-duration.js +78 -0
- package/dist/agent/session-end-flush.d.ts +66 -0
- package/dist/agent/session-end-flush.js +217 -0
- package/dist/commands/cli/native-engine-commands.js +6 -1
- package/dist/commands/enhanced-command-handler.js +5 -0
- package/dist/commands/goal-cli.d.ts +41 -0
- package/dist/commands/goal-cli.js +97 -0
- package/dist/commands/handlers/goal-handler.d.ts +27 -0
- package/dist/commands/handlers/goal-handler.js +128 -0
- package/dist/commands/handlers/index.d.ts +1 -0
- package/dist/commands/handlers/index.js +2 -0
- package/dist/commands/slash/builtin-commands.js +20 -0
- package/dist/config/env-schema.js +14 -0
- package/dist/config/feature-flags.js +7 -0
- package/dist/context/context-manager-v2.d.ts +39 -0
- package/dist/context/context-manager-v2.js +90 -0
- package/dist/daemon/agent-task-executor.js +12 -1
- package/dist/daemon/autonomous-daemon.js +2 -0
- package/dist/daemon/autonomous-loop.d.ts +21 -1
- package/dist/daemon/autonomous-loop.js +60 -0
- package/dist/daemon/colab-goal.d.ts +38 -0
- package/dist/daemon/colab-goal.js +73 -0
- package/dist/fleet/colab-store.d.ts +21 -0
- package/dist/fleet/colab-store.js +16 -0
- package/dist/fleet/peer-session-bridge.d.ts +1 -1
- package/dist/fleet/peer-session-bridge.js +205 -2
- package/dist/fleet/peer-session-store.d.ts +3 -0
- package/dist/fleet/privacy-lint.d.ts +8 -0
- package/dist/fleet/privacy-lint.js +22 -0
- package/dist/goals/goal-judge.d.ts +36 -0
- package/dist/goals/goal-judge.js +129 -0
- package/dist/goals/goal-loop.d.ts +23 -0
- package/dist/goals/goal-loop.js +56 -0
- package/dist/goals/goal-manager.d.ts +71 -0
- package/dist/goals/goal-manager.js +236 -0
- package/dist/goals/goal-state.d.ts +86 -0
- package/dist/goals/goal-state.js +245 -0
- package/dist/goals/goal-store.d.ts +25 -0
- package/dist/goals/goal-store.js +71 -0
- package/dist/goals/index.d.ts +5 -0
- package/dist/goals/index.js +6 -0
- package/dist/hooks/use-input-handler.js +36 -1
- package/dist/index.js +44 -2
- package/dist/observability/run-store.d.ts +1 -1
- package/dist/server/websocket/fleet-bridge.d.ts +13 -1
- package/dist/server/websocket/fleet-bridge.js +11 -0
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { chooseAutonomousModel, } from '../agent/model-tier.js';
|
|
24
24
|
import { beginFleetWork, isFleetSaturated } from '../fleet/fleet-load.js';
|
|
25
|
+
import { DEFAULT_COLAB_GOAL_MAX_TURNS } from './colab-goal.js';
|
|
25
26
|
export class FleetAutonomousLoop {
|
|
26
27
|
store;
|
|
27
28
|
tierConfig;
|
|
@@ -35,12 +36,14 @@ export class FleetAutonomousLoop {
|
|
|
35
36
|
* success. Resets across process restarts — escalation is a within-run feature.
|
|
36
37
|
*/
|
|
37
38
|
failures = new Map();
|
|
39
|
+
goalJudge;
|
|
38
40
|
constructor(config) {
|
|
39
41
|
this.store = config.store;
|
|
40
42
|
this.tierConfig = config.tierConfig;
|
|
41
43
|
this.executor = config.executor;
|
|
42
44
|
this.policy = config.policy ?? {};
|
|
43
45
|
this.enabled = config.enabled ?? (() => true);
|
|
46
|
+
this.goalJudge = config.goalJudge;
|
|
44
47
|
}
|
|
45
48
|
/** Run a single autonomous tick. Never throws — failures are logged + reported. */
|
|
46
49
|
async tick() {
|
|
@@ -88,6 +91,14 @@ export class FleetAutonomousLoop {
|
|
|
88
91
|
doneLoad();
|
|
89
92
|
}
|
|
90
93
|
if (result.ok) {
|
|
94
|
+
// Goal-mode gate (Hermes kanban goal-mode): a successful attempt is not
|
|
95
|
+
// enough — the judge must confirm the task's criteria are satisfied.
|
|
96
|
+
if (task.goalMode && this.goalJudge) {
|
|
97
|
+
const goalOutcome = await this.evaluateGoalModeTask(task, result, model);
|
|
98
|
+
if (goalOutcome)
|
|
99
|
+
return goalOutcome;
|
|
100
|
+
// null → judge said done (or skipped): fall through to completion.
|
|
101
|
+
}
|
|
91
102
|
this.failures.delete(task.id);
|
|
92
103
|
this.store.completeTask(task.id, {
|
|
93
104
|
summary: result.summary,
|
|
@@ -118,5 +129,54 @@ export class FleetAutonomousLoop {
|
|
|
118
129
|
...(result.error ? { detail: result.error } : {}),
|
|
119
130
|
};
|
|
120
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Goal-mode decision ladder. Returns a TickResult when the loop should NOT
|
|
134
|
+
* complete the task (judge says continue / budget exhausted), or null when
|
|
135
|
+
* the task may complete (judge done/skipped, or judge unreachable —
|
|
136
|
+
* fail-open like the interactive Ralph loop).
|
|
137
|
+
*/
|
|
138
|
+
async evaluateGoalModeTask(task, result, model) {
|
|
139
|
+
let verdict;
|
|
140
|
+
try {
|
|
141
|
+
verdict = await this.goalJudge(task, result, model);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null; // fail-open: an unusable judge never blocks completion
|
|
145
|
+
}
|
|
146
|
+
if (verdict.verdict !== 'continue')
|
|
147
|
+
return null;
|
|
148
|
+
const maxTurns = task.goalMaxTurns ?? DEFAULT_COLAB_GOAL_MAX_TURNS;
|
|
149
|
+
const turnsUsed = (task.goalTurnsUsed ?? 0) + 1;
|
|
150
|
+
if (turnsUsed >= maxTurns) {
|
|
151
|
+
// Hermes rule: block for human review instead of spinning forever.
|
|
152
|
+
const reason = `goal budget exhausted (${turnsUsed}/${maxTurns}) — judge: ${verdict.reason}`;
|
|
153
|
+
this.store.blockTask(task.id, reason);
|
|
154
|
+
this.store.appendWorklog({
|
|
155
|
+
agent: this.store.agentId,
|
|
156
|
+
taskId: task.id,
|
|
157
|
+
summary: `Goal-mode blocked after ${turnsUsed}/${maxTurns} turns: ${verdict.reason}`,
|
|
158
|
+
filesModified: result.filesModified ?? [],
|
|
159
|
+
issues: [reason],
|
|
160
|
+
nextSteps: ['human review: unblock, split, or complete the task manually'],
|
|
161
|
+
});
|
|
162
|
+
this.store.updatePresence({ status: 'idle', currentTask: null });
|
|
163
|
+
return { outcome: 'goal_blocked', taskId: task.id, taskTitle: task.title, model, detail: verdict.reason };
|
|
164
|
+
}
|
|
165
|
+
// Under budget: persist the consumed turn + reason, release the task so a
|
|
166
|
+
// later tick continues it with the continuation nudge. A judge "continue"
|
|
167
|
+
// is NOT an executor failure — the model ladder must not escalate.
|
|
168
|
+
this.store.recordGoalTurn(task.id, verdict.reason);
|
|
169
|
+
this.store.appendWorklog({
|
|
170
|
+
agent: this.store.agentId,
|
|
171
|
+
taskId: task.id,
|
|
172
|
+
summary: `Goal-mode turn ${turnsUsed}/${maxTurns} — judge: continue: ${verdict.reason}`,
|
|
173
|
+
filesModified: result.filesModified ?? [],
|
|
174
|
+
issues: [],
|
|
175
|
+
nextSteps: ['continue on a later tick with the goal continuation nudge'],
|
|
176
|
+
});
|
|
177
|
+
this.store.releaseTask(task.id);
|
|
178
|
+
this.store.updatePresence({ status: 'idle', currentTask: null });
|
|
179
|
+
return { outcome: 'goal_continue', taskId: task.id, taskTitle: task.title, model, detail: verdict.reason };
|
|
180
|
+
}
|
|
121
181
|
}
|
|
122
182
|
//# sourceMappingURL=autonomous-loop.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-mode for fleet colab tasks — port of Hermes Agent's kanban goal-mode.
|
|
3
|
+
*
|
|
4
|
+
* A task with `goalMode: true` is not completed on the worker's first
|
|
5
|
+
* successful attempt: an LLM judge checks the task's title/description (with
|
|
6
|
+
* `acceptanceCriteria` as strict numbered criteria) against the worker's
|
|
7
|
+
* output. "Continue" re-opens the task with a continuation nudge so the next
|
|
8
|
+
* tick keeps going; once `goalMaxTurns` is spent the task is BLOCKED for
|
|
9
|
+
* human review instead of spinning (Hermes' "block instead of loop" rule).
|
|
10
|
+
*
|
|
11
|
+
* The judge is fail-open (judgeGoal semantics): a broken judge yields
|
|
12
|
+
* "continue", and the turn budget is the backstop.
|
|
13
|
+
*/
|
|
14
|
+
import type { ColabTask } from '../fleet/colab-store.js';
|
|
15
|
+
import { GoalJudgeResult } from '../goals/goal-judge.js';
|
|
16
|
+
import type { AutonomousModelChoice } from '../agent/model-tier.js';
|
|
17
|
+
import type { TaskExecutionResult } from './autonomous-loop.js';
|
|
18
|
+
/**
|
|
19
|
+
* Conservative default for unattended subprocess loops (Hermes' kanban
|
|
20
|
+
* example uses 7; interactive /goal uses 20).
|
|
21
|
+
*/
|
|
22
|
+
export declare const DEFAULT_COLAB_GOAL_MAX_TURNS = 5;
|
|
23
|
+
export type ColabGoalJudge = (task: ColabTask, result: TaskExecutionResult, model: AutonomousModelChoice) => Promise<GoalJudgeResult>;
|
|
24
|
+
/** The goal text the judge evaluates: title + description. */
|
|
25
|
+
export declare function goalTextForTask(task: Pick<ColabTask, 'title' | 'description'>): string;
|
|
26
|
+
/**
|
|
27
|
+
* Continuation nudge fed to the worker on later goal-mode turns (port of
|
|
28
|
+
* Hermes' KANBAN_GOAL_CONTINUATION_TEMPLATE, adapted to the colab lifecycle:
|
|
29
|
+
* completion is decided by the loop's judge, so the worker is nudged to
|
|
30
|
+
* finish and state the outcome explicitly).
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildColabGoalContinuationPrompt(task: ColabTask): string;
|
|
33
|
+
/**
|
|
34
|
+
* Default judge: a one-shot call on the same tier model the worker ran on
|
|
35
|
+
* (local tiers stay free), overridable via `goals.judgeModel`. Task
|
|
36
|
+
* `acceptanceCriteria` become strict numbered criteria for the judge.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createColabGoalJudge(): ColabGoalJudge;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-mode for fleet colab tasks — port of Hermes Agent's kanban goal-mode.
|
|
3
|
+
*
|
|
4
|
+
* A task with `goalMode: true` is not completed on the worker's first
|
|
5
|
+
* successful attempt: an LLM judge checks the task's title/description (with
|
|
6
|
+
* `acceptanceCriteria` as strict numbered criteria) against the worker's
|
|
7
|
+
* output. "Continue" re-opens the task with a continuation nudge so the next
|
|
8
|
+
* tick keeps going; once `goalMaxTurns` is spent the task is BLOCKED for
|
|
9
|
+
* human review instead of spinning (Hermes' "block instead of loop" rule).
|
|
10
|
+
*
|
|
11
|
+
* The judge is fail-open (judgeGoal semantics): a broken judge yields
|
|
12
|
+
* "continue", and the turn budget is the backstop.
|
|
13
|
+
*/
|
|
14
|
+
import { CodeBuddyClient } from '../codebuddy/client.js';
|
|
15
|
+
import { judgeGoal } from '../goals/goal-judge.js';
|
|
16
|
+
import { resolveGoalsConfig } from '../goals/goal-manager.js';
|
|
17
|
+
import { logger } from '../utils/logger.js';
|
|
18
|
+
/**
|
|
19
|
+
* Conservative default for unattended subprocess loops (Hermes' kanban
|
|
20
|
+
* example uses 7; interactive /goal uses 20).
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_COLAB_GOAL_MAX_TURNS = 5;
|
|
23
|
+
/** The goal text the judge evaluates: title + description. */
|
|
24
|
+
export function goalTextForTask(task) {
|
|
25
|
+
return `${task.title}\n\n${task.description ?? ''}`.trim();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Continuation nudge fed to the worker on later goal-mode turns (port of
|
|
29
|
+
* Hermes' KANBAN_GOAL_CONTINUATION_TEMPLATE, adapted to the colab lifecycle:
|
|
30
|
+
* completion is decided by the loop's judge, so the worker is nudged to
|
|
31
|
+
* finish and state the outcome explicitly).
|
|
32
|
+
*/
|
|
33
|
+
export function buildColabGoalContinuationPrompt(task) {
|
|
34
|
+
const criteria = task.acceptanceCriteria?.length
|
|
35
|
+
? `\nAcceptance criteria (ALL must be satisfied):\n${task.acceptanceCriteria
|
|
36
|
+
.map((text, i) => `- ${i + 1}. ${text}`)
|
|
37
|
+
.join('\n')}\n`
|
|
38
|
+
: '';
|
|
39
|
+
const lastReason = task.goalLastReason ? `\nJudge's last verdict: ${task.goalLastReason}\n` : '';
|
|
40
|
+
return ('[Continuing toward this fleet task — the judge says it is not done yet]\n' +
|
|
41
|
+
`Task: ${goalTextForTask(task)}\n` +
|
|
42
|
+
criteria +
|
|
43
|
+
lastReason +
|
|
44
|
+
'\nFinish the remaining work. When everything is done, state completion ' +
|
|
45
|
+
'explicitly with evidence (file contents, command output). If you are ' +
|
|
46
|
+
'blocked, state the blocker clearly.');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Default judge: a one-shot call on the same tier model the worker ran on
|
|
50
|
+
* (local tiers stay free), overridable via `goals.judgeModel`. Task
|
|
51
|
+
* `acceptanceCriteria` become strict numbered criteria for the judge.
|
|
52
|
+
*/
|
|
53
|
+
export function createColabGoalJudge() {
|
|
54
|
+
return async (task, result, model) => {
|
|
55
|
+
try {
|
|
56
|
+
const config = resolveGoalsConfig();
|
|
57
|
+
const client = new CodeBuddyClient(process.env.GROK_API_KEY || process.env.OPENAI_API_KEY || 'local', config.judgeModel || model.model, model.baseUrl);
|
|
58
|
+
return await judgeGoal(client, {
|
|
59
|
+
goal: goalTextForTask(task),
|
|
60
|
+
lastResponse: result.output || result.summary,
|
|
61
|
+
...(task.acceptanceCriteria?.length ? { subgoals: task.acceptanceCriteria } : {}),
|
|
62
|
+
...(config.judgeModel ? { model: config.judgeModel } : {}),
|
|
63
|
+
timeoutMs: config.judgeTimeoutMs,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// Fail-open, like judgeGoal itself: never wedge the loop on judge setup.
|
|
68
|
+
logger.debug('colab goal judge: setup failed — continue', { error: String(error) });
|
|
69
|
+
return { verdict: 'continue', reason: `judge setup error: ${String(error)}`, parseFailed: false };
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=colab-goal.js.map
|
|
@@ -48,6 +48,20 @@ export interface ColabTask {
|
|
|
48
48
|
verifyCommand?: string;
|
|
49
49
|
/** Ids of tasks that must be `completed` before this one is claimable (DAG). */
|
|
50
50
|
dependsOn?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Goal-mode (Hermes kanban goal-mode parity): the worker loops on this task
|
|
53
|
+
* — after each attempt an LLM judge checks the title/description (+
|
|
54
|
+
* `acceptanceCriteria` as strict criteria); "continue" re-opens the task
|
|
55
|
+
* with a continuation nudge until `goalMaxTurns` is spent, then it is
|
|
56
|
+
* BLOCKED for human review instead of spinning.
|
|
57
|
+
*/
|
|
58
|
+
goalMode?: boolean;
|
|
59
|
+
/** Per-task turn budget for goal-mode (default 5). */
|
|
60
|
+
goalMaxTurns?: number;
|
|
61
|
+
/** Goal-mode turns consumed so far (persisted — survives daemon restarts). */
|
|
62
|
+
goalTurnsUsed?: number;
|
|
63
|
+
/** Last judge reason (shown in the next continuation nudge). */
|
|
64
|
+
goalLastReason?: string;
|
|
51
65
|
createdBy?: string;
|
|
52
66
|
createdAt?: string;
|
|
53
67
|
}
|
|
@@ -104,6 +118,8 @@ export interface AddTaskInput {
|
|
|
104
118
|
acceptanceCriteria?: string[];
|
|
105
119
|
verifyCommand?: string;
|
|
106
120
|
dependsOn?: string[];
|
|
121
|
+
goalMode?: boolean;
|
|
122
|
+
goalMaxTurns?: number;
|
|
107
123
|
createdBy?: string;
|
|
108
124
|
id?: string;
|
|
109
125
|
}
|
|
@@ -164,6 +180,11 @@ export declare class FleetColabStore {
|
|
|
164
180
|
};
|
|
165
181
|
/** Block a task with a reason (needs human/unblocking). */
|
|
166
182
|
blockTask(taskId: string, reason: string): ColabTask;
|
|
183
|
+
/**
|
|
184
|
+
* Record a consumed goal-mode turn (persisted, so the budget survives daemon
|
|
185
|
+
* restarts) and remember the judge's reason for the next continuation nudge.
|
|
186
|
+
*/
|
|
187
|
+
recordGoalTurn(taskId: string, reason: string): ColabTask;
|
|
167
188
|
/** Release a claimed task back to the open pool. */
|
|
168
189
|
releaseTask(taskId: string): ColabTask;
|
|
169
190
|
addTask(input: AddTaskInput): ColabTask;
|
|
@@ -207,6 +207,20 @@ export class FleetColabStore {
|
|
|
207
207
|
this.writeTasks(file);
|
|
208
208
|
return { ...task };
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Record a consumed goal-mode turn (persisted, so the budget survives daemon
|
|
212
|
+
* restarts) and remember the judge's reason for the next continuation nudge.
|
|
213
|
+
*/
|
|
214
|
+
recordGoalTurn(taskId, reason) {
|
|
215
|
+
const file = this.readTasks();
|
|
216
|
+
const task = file.tasks.find((t) => t.id === taskId);
|
|
217
|
+
if (!task)
|
|
218
|
+
throw new Error(`Unknown fleet task '${taskId}'`);
|
|
219
|
+
task.goalTurnsUsed = (task.goalTurnsUsed ?? 0) + 1;
|
|
220
|
+
task.goalLastReason = reason;
|
|
221
|
+
this.writeTasks(file);
|
|
222
|
+
return { ...task };
|
|
223
|
+
}
|
|
210
224
|
/** Release a claimed task back to the open pool. */
|
|
211
225
|
releaseTask(taskId) {
|
|
212
226
|
const file = this.readTasks();
|
|
@@ -234,6 +248,8 @@ export class FleetColabStore {
|
|
|
234
248
|
...(input.acceptanceCriteria ? { acceptanceCriteria: input.acceptanceCriteria } : {}),
|
|
235
249
|
...(input.verifyCommand ? { verifyCommand: input.verifyCommand } : {}),
|
|
236
250
|
...(input.dependsOn && input.dependsOn.length > 0 ? { dependsOn: [...new Set(input.dependsOn)] } : {}),
|
|
251
|
+
...(input.goalMode ? { goalMode: true } : {}),
|
|
252
|
+
...(input.goalMaxTurns && input.goalMaxTurns > 0 ? { goalMaxTurns: Math.trunc(input.goalMaxTurns) } : {}),
|
|
237
253
|
createdBy: input.createdBy ?? this.agentId,
|
|
238
254
|
createdAt: this.isoNow(),
|
|
239
255
|
};
|
|
@@ -42,7 +42,7 @@ export type PeerChatClientGetter = () => CodeBuddyClient | null;
|
|
|
42
42
|
* getter; un-wire first if you need to swap).
|
|
43
43
|
*/
|
|
44
44
|
export declare function wirePeerSessionBridge(getClient: PeerChatClientGetter): Promise<void>;
|
|
45
|
-
/** Detach all
|
|
45
|
+
/** Detach all registered methods. Idempotent. Does NOT clear in-memory sessions. */
|
|
46
46
|
export declare function unwirePeerSessionBridge(): void;
|
|
47
47
|
/** Whether the bridge is currently registered on the peer-rpc registry. */
|
|
48
48
|
export declare function isPeerSessionBridgeWired(): boolean;
|
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { beginFleetWork } from './fleet-load.js';
|
|
33
33
|
import { registerPeerMethod, unregisterPeerMethod } from '../server/websocket/peer-rpc.js';
|
|
34
|
-
import { broadcastChatSessionEnd, broadcastChatSessionStart, broadcastChatSessionTurn, } from '../server/websocket/fleet-bridge.js';
|
|
34
|
+
import { broadcastChatSessionEnd, broadcastChatSessionGoal, broadcastChatSessionStart, broadcastChatSessionTurn, } from '../server/websocket/fleet-bridge.js';
|
|
35
|
+
import { judgeGoal } from '../goals/goal-judge.js';
|
|
36
|
+
import { resolveGoalsConfig } from '../goals/goal-manager.js';
|
|
37
|
+
import { applyJudgeOutcome, createGoalState, formatGoalStatusLine, normalizeGoalState, renderSubgoalsBlock, } from '../goals/goal-state.js';
|
|
35
38
|
import { logger } from '../utils/logger.js';
|
|
36
39
|
import { getPeerSessionStore, } from './peer-session-store.js';
|
|
37
40
|
import { DEFAULT_DISPATCH_POLICY_PREVIEW_TOOLS, FLEET_DISPATCH_PROFILES, buildDispatchSystemPrompt, buildHermesToolsetDescriptor, getDispatchToolPolicy, isFleetDispatchProfile, mergeDispatchSystemPrompt, normalizeDispatchProfile, } from './dispatch-profile.js';
|
|
@@ -144,10 +147,60 @@ function snapshot(session) {
|
|
|
144
147
|
toolDecisions: session.toolDecisions,
|
|
145
148
|
toolset: session.toolset,
|
|
146
149
|
messages: [...session.messages],
|
|
150
|
+
...(session.goal ? { goal: { ...session.goal, subgoals: [...session.goal.subgoals] } } : {}),
|
|
147
151
|
createdAt: session.createdAt,
|
|
148
152
|
lastUsedAt: session.lastUsedAt,
|
|
149
153
|
};
|
|
150
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Post-turn goal hook shared by `continue` and `continue-stream`. Judges the
|
|
157
|
+
* assistant's response against the session goal, applies the Hermes decision
|
|
158
|
+
* ladder, and returns the report embedded in the RPC response. The judge is
|
|
159
|
+
* fail-open; this never throws. The caller persists the session afterwards.
|
|
160
|
+
*/
|
|
161
|
+
async function evaluateSessionGoalAfterTurn(session, assistantText) {
|
|
162
|
+
const goal = session.goal;
|
|
163
|
+
if (!goal || goal.status !== 'active')
|
|
164
|
+
return null;
|
|
165
|
+
if (!assistantText.trim())
|
|
166
|
+
return null; // empty turn: don't burn budget
|
|
167
|
+
let report;
|
|
168
|
+
try {
|
|
169
|
+
const config = resolveGoalsConfig();
|
|
170
|
+
const outcome = await judgeGoal(cachedGetter?.() ?? null, {
|
|
171
|
+
goal: goal.goal,
|
|
172
|
+
lastResponse: assistantText,
|
|
173
|
+
...(goal.subgoals.length ? { subgoals: goal.subgoals } : {}),
|
|
174
|
+
...(config.judgeModel ? { model: config.judgeModel } : {}),
|
|
175
|
+
timeoutMs: config.judgeTimeoutMs,
|
|
176
|
+
});
|
|
177
|
+
const decision = applyJudgeOutcome(goal, outcome);
|
|
178
|
+
report = {
|
|
179
|
+
status: decision.status,
|
|
180
|
+
verdict: decision.verdict,
|
|
181
|
+
reason: decision.reason,
|
|
182
|
+
turnsUsed: goal.turnsUsed,
|
|
183
|
+
maxTurns: goal.maxTurns,
|
|
184
|
+
message: decision.message,
|
|
185
|
+
...(decision.continuationPrompt ? { continuationPrompt: decision.continuationPrompt } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
logger.warn('[peer-session-bridge] goal evaluation failed — skipping this turn', {
|
|
190
|
+
sessionId: session.sessionId,
|
|
191
|
+
error: err instanceof Error ? err.message : String(err),
|
|
192
|
+
});
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
broadcastChatSessionGoal({
|
|
196
|
+
sessionId: session.sessionId,
|
|
197
|
+
status: report.status,
|
|
198
|
+
verdict: report.verdict,
|
|
199
|
+
turnsUsed: report.turnsUsed,
|
|
200
|
+
maxTurns: report.maxTurns,
|
|
201
|
+
});
|
|
202
|
+
return report;
|
|
203
|
+
}
|
|
151
204
|
/**
|
|
152
205
|
* Register the `peer.chat-session.*` methods. The `getClient` closure
|
|
153
206
|
* is captured and called fresh on each invocation so the caller can
|
|
@@ -174,6 +227,7 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
174
227
|
for (const p of persisted) {
|
|
175
228
|
if (now - p.lastUsedAt > idleMs)
|
|
176
229
|
continue; // double-check vs the boundary
|
|
230
|
+
const goal = p.goal ? normalizeGoalState(p.goal) : null;
|
|
177
231
|
sessions.set(p.sessionId, {
|
|
178
232
|
sessionId: p.sessionId,
|
|
179
233
|
systemPrompt: p.systemPrompt,
|
|
@@ -183,6 +237,7 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
183
237
|
toolDecisions: p.toolDecisions,
|
|
184
238
|
toolset: p.toolset,
|
|
185
239
|
messages: [...p.messages],
|
|
240
|
+
...(goal && goal.status !== 'cleared' ? { goal } : {}),
|
|
186
241
|
createdAt: p.createdAt,
|
|
187
242
|
lastUsedAt: p.lastUsedAt,
|
|
188
243
|
pending: Promise.resolve(),
|
|
@@ -312,6 +367,11 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
312
367
|
const text = response?.choices?.[0]?.message?.content ?? '';
|
|
313
368
|
session.messages.push({ role: 'assistant', content: text });
|
|
314
369
|
session.lastUsedAt = Date.now();
|
|
370
|
+
// Goal Ralph-loop (Hermes gateway parity): judge the turn server-side,
|
|
371
|
+
// mutate the session goal state, and report the verdict to the caller
|
|
372
|
+
// (who drives the continuation). Runs BEFORE the disk flush so the
|
|
373
|
+
// snapshot below persists the updated goal counters.
|
|
374
|
+
const goalReport = await evaluateSessionGoalAfterTurn(session, text);
|
|
315
375
|
// V1.2-saga — flush the new turn to disk before returning so a
|
|
316
376
|
// crash mid-conversation can be replayed on next boot. Failure
|
|
317
377
|
// logs but doesn't fail the turn: the caller already got an
|
|
@@ -340,6 +400,7 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
340
400
|
finishReason: response?.choices?.[0]?.finish_reason,
|
|
341
401
|
usage: response?.usage,
|
|
342
402
|
traceId: ctx.traceId,
|
|
403
|
+
...(goalReport ? { goal: goalReport } : {}),
|
|
343
404
|
...sessionPolicyMetadata(session),
|
|
344
405
|
};
|
|
345
406
|
};
|
|
@@ -439,6 +500,8 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
439
500
|
}
|
|
440
501
|
session.messages.push({ role: 'assistant', content: aggregate });
|
|
441
502
|
session.lastUsedAt = Date.now();
|
|
503
|
+
// Goal Ralph-loop — same server-side judge as the non-streaming path.
|
|
504
|
+
const goalReport = await evaluateSessionGoalAfterTurn(session, aggregate);
|
|
442
505
|
try {
|
|
443
506
|
await getPeerSessionStore().save(snapshot(session));
|
|
444
507
|
}
|
|
@@ -460,6 +523,7 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
460
523
|
finishReason,
|
|
461
524
|
usage,
|
|
462
525
|
traceId: ctx.traceId,
|
|
526
|
+
...(goalReport ? { goal: goalReport } : {}),
|
|
463
527
|
...sessionPolicyMetadata(session),
|
|
464
528
|
};
|
|
465
529
|
};
|
|
@@ -497,6 +561,143 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
497
561
|
traceId: ctx.traceId,
|
|
498
562
|
};
|
|
499
563
|
});
|
|
564
|
+
// Goal Ralph-loop controls (Hermes gateway parity). One method, action-
|
|
565
|
+
// dispatched: set | status | pause | resume | clear | subgoal-add |
|
|
566
|
+
// subgoal-list | subgoal-remove | subgoal-clear. Mirrors the /goal +
|
|
567
|
+
// /subgoal slash surface. Setting a NEW goal while one is active is
|
|
568
|
+
// rejected (Hermes mid-run rule) — pause/clear first. status/pause/
|
|
569
|
+
// resume/clear are safe mid-turn: they only touch goal metadata.
|
|
570
|
+
registerPeerMethod('peer.chat-session.goal', async (params, ctx) => {
|
|
571
|
+
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
|
|
572
|
+
const action = typeof params.action === 'string' ? params.action : 'status';
|
|
573
|
+
if (!sessionId) {
|
|
574
|
+
throw new Error('peer.chat-session.goal: sessionId is required (string)');
|
|
575
|
+
}
|
|
576
|
+
const session = sessions.get(sessionId);
|
|
577
|
+
if (!session) {
|
|
578
|
+
throw new Error(`SESSION_NOT_FOUND: no session with id "${sessionId}"`);
|
|
579
|
+
}
|
|
580
|
+
const persist = async () => {
|
|
581
|
+
try {
|
|
582
|
+
await getPeerSessionStore().save(snapshot(session));
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
logger.warn('[peer-session-bridge] save on goal mutation failed', {
|
|
586
|
+
sessionId,
|
|
587
|
+
error: err instanceof Error ? err.message : String(err),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
const emitGoal = (status) => {
|
|
592
|
+
broadcastChatSessionGoal({
|
|
593
|
+
sessionId,
|
|
594
|
+
status,
|
|
595
|
+
...(session.goal ? { turnsUsed: session.goal.turnsUsed, maxTurns: session.goal.maxTurns } : {}),
|
|
596
|
+
});
|
|
597
|
+
};
|
|
598
|
+
const goalView = () => session.goal
|
|
599
|
+
? {
|
|
600
|
+
goal: session.goal.goal,
|
|
601
|
+
status: session.goal.status,
|
|
602
|
+
turnsUsed: session.goal.turnsUsed,
|
|
603
|
+
maxTurns: session.goal.maxTurns,
|
|
604
|
+
subgoals: [...session.goal.subgoals],
|
|
605
|
+
...(session.goal.lastVerdict ? { lastVerdict: session.goal.lastVerdict } : {}),
|
|
606
|
+
...(session.goal.lastReason ? { lastReason: session.goal.lastReason } : {}),
|
|
607
|
+
...(session.goal.pausedReason ? { pausedReason: session.goal.pausedReason } : {}),
|
|
608
|
+
statusLine: formatGoalStatusLine(session.goal),
|
|
609
|
+
}
|
|
610
|
+
: { status: 'none', statusLine: formatGoalStatusLine(null) };
|
|
611
|
+
switch (action) {
|
|
612
|
+
case 'set': {
|
|
613
|
+
const text = typeof params.goal === 'string' ? params.goal.trim() : '';
|
|
614
|
+
if (!text) {
|
|
615
|
+
throw new Error('peer.chat-session.goal: action "set" requires goal text (string)');
|
|
616
|
+
}
|
|
617
|
+
if (session.goal?.status === 'active') {
|
|
618
|
+
throw new Error('GOAL_ACTIVE: a goal is already active on this session. ' +
|
|
619
|
+
'Use action "status"/"pause"/"clear" mid-run; clear it before setting a new goal.');
|
|
620
|
+
}
|
|
621
|
+
const maxTurns = typeof params.maxTurns === 'number' && params.maxTurns > 0
|
|
622
|
+
? Math.trunc(params.maxTurns)
|
|
623
|
+
: resolveGoalsConfig().maxTurns;
|
|
624
|
+
session.goal = createGoalState(text, maxTurns);
|
|
625
|
+
await persist();
|
|
626
|
+
emitGoal('active');
|
|
627
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
628
|
+
}
|
|
629
|
+
case 'status':
|
|
630
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
631
|
+
case 'pause': {
|
|
632
|
+
if (!session.goal)
|
|
633
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
634
|
+
session.goal.status = 'paused';
|
|
635
|
+
session.goal.pausedReason = 'user-paused';
|
|
636
|
+
await persist();
|
|
637
|
+
emitGoal('paused');
|
|
638
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
639
|
+
}
|
|
640
|
+
case 'resume': {
|
|
641
|
+
if (!session.goal)
|
|
642
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
643
|
+
session.goal.status = 'active';
|
|
644
|
+
delete session.goal.pausedReason;
|
|
645
|
+
session.goal.turnsUsed = 0; // Hermes resume semantics: budget reset
|
|
646
|
+
await persist();
|
|
647
|
+
emitGoal('active');
|
|
648
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
649
|
+
}
|
|
650
|
+
case 'clear': {
|
|
651
|
+
const had = Boolean(session.goal);
|
|
652
|
+
delete session.goal;
|
|
653
|
+
await persist();
|
|
654
|
+
if (had)
|
|
655
|
+
emitGoal('cleared');
|
|
656
|
+
return { cleared: had, ...goalView(), traceId: ctx.traceId };
|
|
657
|
+
}
|
|
658
|
+
case 'subgoal-add': {
|
|
659
|
+
const text = typeof params.text === 'string' ? params.text.trim() : '';
|
|
660
|
+
if (!text) {
|
|
661
|
+
throw new Error('peer.chat-session.goal: action "subgoal-add" requires text (string)');
|
|
662
|
+
}
|
|
663
|
+
if (!session.goal || !['active', 'paused'].includes(session.goal.status)) {
|
|
664
|
+
throw new Error('NO_ACTIVE_GOAL: set a goal before adding subgoals');
|
|
665
|
+
}
|
|
666
|
+
session.goal.subgoals.push(text);
|
|
667
|
+
await persist();
|
|
668
|
+
return { ...goalView(), traceId: ctx.traceId };
|
|
669
|
+
}
|
|
670
|
+
case 'subgoal-list':
|
|
671
|
+
return {
|
|
672
|
+
...goalView(),
|
|
673
|
+
rendered: session.goal ? renderSubgoalsBlock(session.goal.subgoals) : '',
|
|
674
|
+
traceId: ctx.traceId,
|
|
675
|
+
};
|
|
676
|
+
case 'subgoal-remove': {
|
|
677
|
+
const index = typeof params.index === 'number' ? Math.trunc(params.index) : NaN;
|
|
678
|
+
if (!session.goal || !['active', 'paused'].includes(session.goal.status)) {
|
|
679
|
+
throw new Error('NO_ACTIVE_GOAL: set a goal before removing subgoals');
|
|
680
|
+
}
|
|
681
|
+
if (!Number.isInteger(index) || index < 1 || index > session.goal.subgoals.length) {
|
|
682
|
+
throw new Error(`peer.chat-session.goal: subgoal index out of range (1..${session.goal.subgoals.length})`);
|
|
683
|
+
}
|
|
684
|
+
const [removed] = session.goal.subgoals.splice(index - 1, 1);
|
|
685
|
+
await persist();
|
|
686
|
+
return { removed, ...goalView(), traceId: ctx.traceId };
|
|
687
|
+
}
|
|
688
|
+
case 'subgoal-clear': {
|
|
689
|
+
if (!session.goal || !['active', 'paused'].includes(session.goal.status)) {
|
|
690
|
+
throw new Error('NO_ACTIVE_GOAL: set a goal before clearing subgoals');
|
|
691
|
+
}
|
|
692
|
+
const previous = session.goal.subgoals.length;
|
|
693
|
+
session.goal.subgoals = [];
|
|
694
|
+
await persist();
|
|
695
|
+
return { cleared: previous, ...goalView(), traceId: ctx.traceId };
|
|
696
|
+
}
|
|
697
|
+
default:
|
|
698
|
+
throw new Error(`peer.chat-session.goal: unknown action "${action}" (expected set | status | pause | resume | clear | subgoal-add | subgoal-list | subgoal-remove | subgoal-clear)`);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
500
701
|
registerPeerMethod('peer.chat-session.end', async (params, ctx) => {
|
|
501
702
|
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
|
|
502
703
|
if (!sessionId) {
|
|
@@ -520,13 +721,14 @@ export async function wirePeerSessionBridge(getClient) {
|
|
|
520
721
|
wired = true;
|
|
521
722
|
logger.debug('[peer-session-bridge] wired');
|
|
522
723
|
}
|
|
523
|
-
/** Detach all
|
|
724
|
+
/** Detach all registered methods. Idempotent. Does NOT clear in-memory sessions. */
|
|
524
725
|
export function unwirePeerSessionBridge() {
|
|
525
726
|
if (!wired)
|
|
526
727
|
return;
|
|
527
728
|
unregisterPeerMethod('peer.chat-session.start');
|
|
528
729
|
unregisterPeerMethod('peer.chat-session.continue');
|
|
529
730
|
unregisterPeerMethod('peer.chat-session.continue-stream');
|
|
731
|
+
unregisterPeerMethod('peer.chat-session.goal');
|
|
530
732
|
unregisterPeerMethod('peer.chat-session.end');
|
|
531
733
|
cachedGetter = null;
|
|
532
734
|
wired = false;
|
|
@@ -546,6 +748,7 @@ export function _unwireForTests() {
|
|
|
546
748
|
unregisterPeerMethod('peer.chat-session.start');
|
|
547
749
|
unregisterPeerMethod('peer.chat-session.continue');
|
|
548
750
|
unregisterPeerMethod('peer.chat-session.continue-stream');
|
|
751
|
+
unregisterPeerMethod('peer.chat-session.goal');
|
|
549
752
|
unregisterPeerMethod('peer.chat-session.list');
|
|
550
753
|
unregisterPeerMethod('peer.chat-session.end');
|
|
551
754
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*
|
|
28
28
|
* @module fleet/peer-session-store
|
|
29
29
|
*/
|
|
30
|
+
import type { GoalState } from '../goals/goal-state.js';
|
|
30
31
|
import type { FleetDispatchProfile, FleetHermesToolsetDescriptor, FleetDispatchToolDecision, FleetDispatchToolPolicy } from './dispatch-profile.js';
|
|
31
32
|
export interface PersistedChatMessage {
|
|
32
33
|
role: 'system' | 'user' | 'assistant';
|
|
@@ -42,6 +43,8 @@ export interface PersistedChatSession {
|
|
|
42
43
|
toolset?: FleetHermesToolsetDescriptor;
|
|
43
44
|
/** User/assistant turns (system prompt is held separately). */
|
|
44
45
|
messages: PersistedChatMessage[];
|
|
46
|
+
/** Standing goal attached via `peer.chat-session.goal` (Hermes gateway parity). */
|
|
47
|
+
goal?: GoalState;
|
|
45
48
|
createdAt: number;
|
|
46
49
|
lastUsedAt: number;
|
|
47
50
|
}
|
|
@@ -35,3 +35,11 @@ export interface PrivacyLintResult {
|
|
|
35
35
|
* Scan a prompt for secrets. Returns matches with previews.
|
|
36
36
|
*/
|
|
37
37
|
export declare function scanForSecrets(prompt: string): PrivacyLintResult;
|
|
38
|
+
/**
|
|
39
|
+
* Replace every secret/PII match with a `[REDACTED:<kind>]` marker.
|
|
40
|
+
*
|
|
41
|
+
* Run this on FULL text before any truncation — cutting a PEM block (or
|
|
42
|
+
* any multi-line secret) in half can hide it from the patterns above.
|
|
43
|
+
* Used by every memory-persistence path (WS3 guard-rail).
|
|
44
|
+
*/
|
|
45
|
+
export declare function redactSecrets(text: string): string;
|
|
@@ -164,4 +164,26 @@ function redactPreview(text, start, end) {
|
|
|
164
164
|
: matched.slice(0, 4) + '…[redacted]…' + matched.slice(-4);
|
|
165
165
|
return `${before}${redacted}${after}`.replace(/\s+/g, ' ').trim();
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Replace every secret/PII match with a `[REDACTED:<kind>]` marker.
|
|
169
|
+
*
|
|
170
|
+
* Run this on FULL text before any truncation — cutting a PEM block (or
|
|
171
|
+
* any multi-line secret) in half can hide it from the patterns above.
|
|
172
|
+
* Used by every memory-persistence path (WS3 guard-rail).
|
|
173
|
+
*/
|
|
174
|
+
export function redactSecrets(text) {
|
|
175
|
+
const lint = scanForSecrets(text);
|
|
176
|
+
if (!lint.hasSecrets)
|
|
177
|
+
return text;
|
|
178
|
+
let out = '';
|
|
179
|
+
let cursor = 0;
|
|
180
|
+
for (const match of [...lint.matches].sort((a, b) => a.start - b.start)) {
|
|
181
|
+
if (match.start < cursor)
|
|
182
|
+
continue; // overlapping match already covered
|
|
183
|
+
out += text.slice(cursor, match.start) + `[REDACTED:${match.kind}]`;
|
|
184
|
+
cursor = match.end;
|
|
185
|
+
}
|
|
186
|
+
out += text.slice(cursor);
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
167
189
|
//# sourceMappingURL=privacy-lint.js.map
|