@monster-orange/den 0.4.22 → 0.4.23
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 +47 -0
- package/docs/distributions.md +91 -30
- package/package.json +1 -1
- package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
- package/src/shared/agents/rotate-settings.ts +309 -0
- package/src/shared/agents/rotation.ts +110 -5
- package/src/shared/den-config.ts +27 -12
- package/src/ui/cli/agent-ops.ts +449 -15
- package/src/ui/cli/agents-command.ts +169 -3
- package/src/ui/cli/server-agent-ops.ts +2 -2
- package/src/ui/cli/web-command.ts +32 -5
- package/src/ui/cli/web-service.ts +160 -0
- package/src/ui/server/agent-routes.ts +149 -5
- package/src/ui/server/index.ts +13 -5
- package/src/ui/server/public/agents-page.js +214 -22
- package/src/ui/server/public/agents.html +32 -0
- package/src/ui/server/public/i18n/es.js +44 -2
- package/src/ui/server/public/i18n/ja.js +40 -2
- package/src/ui/server/public/settings-registry.js +204 -0
- package/src/ui/server/public/settings.html +43 -4
- package/src/ui/server/public/shell.js +42 -2
- package/src/ui/server/route-classes.ts +21 -0
- package/src/ui/server/session-routes.ts +23 -5
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -148,9 +148,25 @@ import {
|
|
|
148
148
|
openElsewhere,
|
|
149
149
|
type RuntimeConversation,
|
|
150
150
|
} from "../../shared/agents/conversations.js";
|
|
151
|
+
import {
|
|
152
|
+
OVERRIDE_SCOPES,
|
|
153
|
+
readRotateFile,
|
|
154
|
+
resolveRotate,
|
|
155
|
+
ROTATE_DEFAULTS,
|
|
156
|
+
setRotateValue,
|
|
157
|
+
sourceText,
|
|
158
|
+
type OrgRotate,
|
|
159
|
+
type OverrideScope,
|
|
160
|
+
type RotateFile,
|
|
161
|
+
type RotateValues,
|
|
162
|
+
} from "../../shared/agents/rotate-settings.js";
|
|
151
163
|
import {
|
|
152
164
|
archiveHandoff,
|
|
153
|
-
|
|
165
|
+
keepConversation,
|
|
166
|
+
noteAsked,
|
|
167
|
+
noteFellBack,
|
|
168
|
+
rotationInstruction,
|
|
169
|
+
syncAfter,
|
|
154
170
|
handoffFile,
|
|
155
171
|
handoffPrompt,
|
|
156
172
|
launchRotation,
|
|
@@ -158,9 +174,10 @@ import {
|
|
|
158
174
|
readRotation,
|
|
159
175
|
writeRotation,
|
|
160
176
|
reasonText,
|
|
161
|
-
rotateAfter,
|
|
162
177
|
rotationInfo,
|
|
178
|
+
type RotationEntry,
|
|
163
179
|
type RotationInfo,
|
|
180
|
+
type RotationPolicy,
|
|
164
181
|
} from "../../shared/agents/rotation.js";
|
|
165
182
|
import {
|
|
166
183
|
binSettingKey,
|
|
@@ -237,6 +254,8 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
237
254
|
) => void;
|
|
238
255
|
/** The user.yml a person edits to set it (default: config/local/user.yml). */
|
|
239
256
|
userConfigFile?: () => string;
|
|
257
|
+
/** D28: workspace `ws`'s services and their folders (the service scope). */
|
|
258
|
+
workspaceServices?: (ws: string) => { address: string; path: string }[];
|
|
240
259
|
}
|
|
241
260
|
|
|
242
261
|
export interface AgentsResolved extends Resolved {
|
|
@@ -252,6 +271,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
252
271
|
userCatalog: (taken: readonly string[]) => CatalogEntry[];
|
|
253
272
|
saveRuntimeBin: NonNullable<AgentsDeps["saveRuntimeBin"]>;
|
|
254
273
|
userConfigFile: () => string;
|
|
274
|
+
workspaceServices: (ws: string) => { address: string; path: string }[];
|
|
255
275
|
}
|
|
256
276
|
|
|
257
277
|
/** The early-exit watch: at most this long, polling the host. */
|
|
@@ -303,6 +323,8 @@ const refused = (message: string, hints: string[] = [], status = 409) =>
|
|
|
303
323
|
new AgentError(message, { status, hints });
|
|
304
324
|
|
|
305
325
|
let workspaceDirFn: ((ws: string) => string) | null = null;
|
|
326
|
+
let servicesFn: ((ws: string) => { address: string; path: string }[]) | null =
|
|
327
|
+
null;
|
|
306
328
|
let foldersFn: ((ws: string) => { under: string[]; exact?: string[] }) | null =
|
|
307
329
|
null;
|
|
308
330
|
/** The workspaces config, for start and resume (loaded on first use). */
|
|
@@ -322,6 +344,15 @@ export async function loadAgentWorkspaces(d: AgentsDeps): Promise<void> {
|
|
|
322
344
|
if (!cfg) throw usage(`workspace "${name}" does not exist`, 404);
|
|
323
345
|
return ws.conversationFolders(name, cfg, getReposRoot());
|
|
324
346
|
};
|
|
347
|
+
const { readCatalog } =
|
|
348
|
+
await import("../../shared/environment-manager/catalog.js");
|
|
349
|
+
servicesFn = (name) => {
|
|
350
|
+
const cfg = ws.readWorkspacesFile().workspaces[name];
|
|
351
|
+
if (!cfg) return [];
|
|
352
|
+
return ws
|
|
353
|
+
.listWorkspaceServices(cfg, readCatalog(), name)
|
|
354
|
+
.map((r) => ({ address: r.address, path: r.repoPath }));
|
|
355
|
+
};
|
|
325
356
|
}
|
|
326
357
|
|
|
327
358
|
/** The distribution's bin shim (as `mcp connect` registers), else Den's CLI. */
|
|
@@ -374,6 +405,8 @@ export function resolveAgentsDeps(d: AgentsDeps): AgentsResolved {
|
|
|
374
405
|
? setWorkspaceRuntimeBin(ws, id, bin)
|
|
375
406
|
: setUserRuntimeBin(id, bin)),
|
|
376
407
|
userConfigFile: d.userConfigFile ?? userConfigPath,
|
|
408
|
+
workspaceServices:
|
|
409
|
+
d.workspaceServices ?? ((ws) => (servicesFn ? servicesFn(ws) : [])),
|
|
377
410
|
});
|
|
378
411
|
}
|
|
379
412
|
|
|
@@ -511,6 +544,8 @@ export interface AgentRow {
|
|
|
511
544
|
rotation?: RotationInfo;
|
|
512
545
|
/** How many times Den relaunched it from a handoff. */
|
|
513
546
|
rotations?: number;
|
|
547
|
+
/** D28: the last one (when, why, the old conversation, by itself or not). */
|
|
548
|
+
lastRotation?: RotationEntry;
|
|
514
549
|
session?: SessionInfo;
|
|
515
550
|
}
|
|
516
551
|
|
|
@@ -566,15 +601,19 @@ export function agentRows(
|
|
|
566
601
|
records: AgentRecord[] = [],
|
|
567
602
|
/** Den's state dir: each agent's rotation is read there (den#4). */
|
|
568
603
|
stateDir?: string,
|
|
604
|
+
/** D28: each agent's effective rotation settings (policyResolver). */
|
|
605
|
+
policyOf?: (rec: AgentRecord) => RotationPolicy | undefined,
|
|
569
606
|
): AgentRow[] {
|
|
570
607
|
const bySession = new Map(records.map((r) => [r.session.id, r]));
|
|
571
608
|
const rows: AgentRow[] = [];
|
|
572
609
|
const shown = new Set<string>();
|
|
573
610
|
const row = (rec: AgentRecord, s?: SessionInfo): AgentRow => {
|
|
574
611
|
const st = rowState(rec, s);
|
|
575
|
-
const rot = stateDir ? rotationInfo(stateDir, rec) : null;
|
|
612
|
+
const rot = stateDir ? rotationInfo(stateDir, rec, policyOf?.(rec)) : null;
|
|
576
613
|
// A rotated conversation is not resumed: it relaunches from its handoff.
|
|
577
|
-
|
|
614
|
+
// D28: under on_due off, its conversation goes on: Resume stays.
|
|
615
|
+
const resumable =
|
|
616
|
+
canResume(rec, st) && !(rot?.ready && rot.onDue !== "off");
|
|
578
617
|
const why =
|
|
579
618
|
isStoppedState(st) && !resumable
|
|
580
619
|
? (resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null))
|
|
@@ -587,7 +626,7 @@ export function agentRows(
|
|
|
587
626
|
state: st,
|
|
588
627
|
startedAt: s?.startedAt ?? rec.startedAt,
|
|
589
628
|
resumable,
|
|
590
|
-
...(!rot?.ready && !personaChangeRefusal(rec)
|
|
629
|
+
...(!(rot?.ready && rot.onDue !== "off") && !personaChangeRefusal(rec)
|
|
591
630
|
? { personaChange: true }
|
|
592
631
|
: {}),
|
|
593
632
|
...(why ? { noResume: why } : {}),
|
|
@@ -597,7 +636,12 @@ export function agentRows(
|
|
|
597
636
|
...(rec.outcome ? { outcome: rec.outcome } : {}),
|
|
598
637
|
...(rec.usage ? { usage: rec.usage } : {}),
|
|
599
638
|
...(rot ? { rotation: rot } : {}),
|
|
600
|
-
...(rec.rotations?.length
|
|
639
|
+
...(rec.rotations?.length
|
|
640
|
+
? {
|
|
641
|
+
rotations: rec.rotations.length,
|
|
642
|
+
lastRotation: rec.rotations[rec.rotations.length - 1],
|
|
643
|
+
}
|
|
644
|
+
: {}),
|
|
601
645
|
...(s ? { session: contractInfo(s) } : {}),
|
|
602
646
|
};
|
|
603
647
|
};
|
|
@@ -695,7 +739,7 @@ export async function listAgents(
|
|
|
695
739
|
const sessions = all.filter((s) => inWorkspace(s, d.dist, ws));
|
|
696
740
|
const records = listRecords(d.stateDir()).filter((r) => r.workspace === ws);
|
|
697
741
|
noteEnded(d.stateDir(), sessions, records);
|
|
698
|
-
const rows = agentRows(sessions, records, d.stateDir());
|
|
742
|
+
const rows = agentRows(sessions, records, d.stateDir(), policyResolver(d));
|
|
699
743
|
return opts.stopped ? rows.filter((r) => isStoppedState(r.state)) : rows;
|
|
700
744
|
}
|
|
701
745
|
|
|
@@ -731,6 +775,113 @@ function personas(
|
|
|
731
775
|
}
|
|
732
776
|
}
|
|
733
777
|
|
|
778
|
+
/**
|
|
779
|
+
* D28: the workspace service whose folder holds `cwd` (the deepest), for
|
|
780
|
+
* the service scope; undefined outside every service or on any error.
|
|
781
|
+
*/
|
|
782
|
+
function serviceAt(
|
|
783
|
+
d: AgentsResolved,
|
|
784
|
+
ws: string,
|
|
785
|
+
cwd: string | undefined,
|
|
786
|
+
): string | undefined {
|
|
787
|
+
if (!cwd) return undefined;
|
|
788
|
+
try {
|
|
789
|
+
let best: { address: string; path: string } | undefined;
|
|
790
|
+
for (const svc of d.workspaceServices(ws)) {
|
|
791
|
+
const rel = path.relative(svc.path, cwd);
|
|
792
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) continue;
|
|
793
|
+
if (!best || svc.path.length > best.path.length) best = svc;
|
|
794
|
+
}
|
|
795
|
+
return best?.address;
|
|
796
|
+
} catch {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* D28: a resolver of each agent's rotation settings, the config and the
|
|
803
|
+
* personas read once. A config or persona that can't be read: undefined
|
|
804
|
+
* (den#4's own rules), never an error in a list.
|
|
805
|
+
*/
|
|
806
|
+
export function policyResolver(
|
|
807
|
+
d: AgentsResolved,
|
|
808
|
+
): (rec: AgentRecord) => RotationPolicy | undefined {
|
|
809
|
+
let cfg: ResolvedAgentsConfig | null | undefined;
|
|
810
|
+
const byWs = new Map<string, Map<string, Persona> | null>();
|
|
811
|
+
return (rec) => {
|
|
812
|
+
if (cfg === undefined) {
|
|
813
|
+
try {
|
|
814
|
+
cfg = d.agentsConfig();
|
|
815
|
+
} catch {
|
|
816
|
+
cfg = null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (!cfg) return undefined;
|
|
820
|
+
const key = isServerScope(rec.workspace) ? rec.workspace : "";
|
|
821
|
+
if (!byWs.has(key)) {
|
|
822
|
+
try {
|
|
823
|
+
byWs.set(key, personas(d, cfg, rec.workspace));
|
|
824
|
+
} catch {
|
|
825
|
+
byWs.set(key, null);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const all = byWs.get(key);
|
|
829
|
+
if (!all) return undefined;
|
|
830
|
+
return effectiveRotation(d, cfg, rec, all.get(rec.persona));
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** D28: agent `rec`'s rotation under its effective settings. */
|
|
835
|
+
export function rotationOf(
|
|
836
|
+
d: AgentsResolved,
|
|
837
|
+
rec: AgentRecord,
|
|
838
|
+
): RotationInfo | null {
|
|
839
|
+
return rotationInfo(d.stateDir(), rec, policyResolver(d)(rec));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* D28: agent `id`'s effective rotation settings and where each comes
|
|
844
|
+
* from. `persona` is its persona (standing, and whether Den's hooks count
|
|
845
|
+
* compactions: without them it never rotates).
|
|
846
|
+
*/
|
|
847
|
+
export function effectiveRotation(
|
|
848
|
+
d: AgentsResolved,
|
|
849
|
+
cfg: ResolvedAgentsConfig,
|
|
850
|
+
who: { id: string; persona: string; workspace: string; cwd?: string },
|
|
851
|
+
persona: Persona | undefined,
|
|
852
|
+
): RotationPolicy {
|
|
853
|
+
const r = resolveRotate(
|
|
854
|
+
{
|
|
855
|
+
agent: who.id,
|
|
856
|
+
persona: who.persona,
|
|
857
|
+
workspace: who.workspace,
|
|
858
|
+
...(serviceAt(d, who.workspace, who.cwd)
|
|
859
|
+
? { service: serviceAt(d, who.workspace, who.cwd) }
|
|
860
|
+
: {}),
|
|
861
|
+
...(persona?.standing ? { standing: true } : {}),
|
|
862
|
+
},
|
|
863
|
+
readRotateFile(d.stateDir()),
|
|
864
|
+
cfg.rotateOrg ??
|
|
865
|
+
(cfg.rotate
|
|
866
|
+
? {
|
|
867
|
+
after_compactions: cfg.rotate.afterCompactions || "never",
|
|
868
|
+
...(cfg.rotate.standing === false ? { standing: false } : {}),
|
|
869
|
+
}
|
|
870
|
+
: undefined),
|
|
871
|
+
);
|
|
872
|
+
return {
|
|
873
|
+
afterCompactions:
|
|
874
|
+
persona && !persona.guardrails?.hooks ? 0 : r.afterCompactions,
|
|
875
|
+
onDue: r.onDue,
|
|
876
|
+
onPrMerged: r.onPrMerged,
|
|
877
|
+
from: {
|
|
878
|
+
after_compactions: sourceText(r.source.after_compactions),
|
|
879
|
+
on_due: sourceText(r.source.on_due),
|
|
880
|
+
on_pr_merged: sourceText(r.source.on_pr_merged),
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
734
885
|
/** A persona as listed: everything but its role prompt. */
|
|
735
886
|
export type ListedPersona = Omit<Persona, "prompt" | "guardrailsSet"> & {
|
|
736
887
|
/** Its permission mode as Den's intent (AG-13), when it is one. */
|
|
@@ -1353,9 +1504,12 @@ function launchInput(
|
|
|
1353
1504
|
if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
1354
1505
|
throw refused(`workspace ${ws}'s directory ${dir} does not exist`);
|
|
1355
1506
|
// den#4: only Den's hooks count compactions; without them, no rotation.
|
|
1356
|
-
const after =
|
|
1357
|
-
|
|
1358
|
-
|
|
1507
|
+
const after = effectiveRotation(
|
|
1508
|
+
d,
|
|
1509
|
+
cfg,
|
|
1510
|
+
{ id, persona: persona.id, workspace: ws, cwd: dir },
|
|
1511
|
+
persona,
|
|
1512
|
+
).afterCompactions;
|
|
1359
1513
|
return {
|
|
1360
1514
|
persona,
|
|
1361
1515
|
workspace: { name: ws, dir },
|
|
@@ -2137,8 +2291,9 @@ export async function resumeAgent(
|
|
|
2137
2291
|
]);
|
|
2138
2292
|
// den#4: a rotated conversation ends there; its role relaunches from
|
|
2139
2293
|
// the handoff, in a fresh one.
|
|
2140
|
-
const rot =
|
|
2141
|
-
|
|
2294
|
+
const rot = rotationOf(d, rec);
|
|
2295
|
+
// D28: on_due off: nothing happens, its conversation goes on.
|
|
2296
|
+
if (rot?.ready && rot.onDue !== "off")
|
|
2142
2297
|
throw new AgentError(
|
|
2143
2298
|
`agent ${rec.id} rotated after ${reasonText(rot.due!, rot.compactions)}: its conversation is not resumed`,
|
|
2144
2299
|
{
|
|
@@ -2566,12 +2721,14 @@ export async function relaunchAgent(
|
|
|
2566
2721
|
d: AgentsResolved,
|
|
2567
2722
|
id: string,
|
|
2568
2723
|
ws?: string,
|
|
2724
|
+
/** D28: Den relaunched it by itself (on_due auto). */
|
|
2725
|
+
o: { auto?: boolean } = {},
|
|
2569
2726
|
): Promise<Relaunched> {
|
|
2570
2727
|
const rec = agentRecord(d, id, ws);
|
|
2571
2728
|
syncCatalog(d);
|
|
2572
2729
|
const rt = runtimeFor(rec.runtime);
|
|
2573
2730
|
const files = agentFilesDir(d.stateDir(), rec.id);
|
|
2574
|
-
const rot =
|
|
2731
|
+
const rot = rotationOf(d, rec);
|
|
2575
2732
|
const fresh = `Start fresh: ${d.cli} agents start -w ${rec.workspace} --persona ${rec.persona} --id ${rec.id}`;
|
|
2576
2733
|
const resume = `Resume: ${d.cli} agents resume ${rec.id}`;
|
|
2577
2734
|
if (!rt.firstPrompt)
|
|
@@ -2604,6 +2761,7 @@ export async function relaunchAgent(
|
|
|
2604
2761
|
reason: rot.due,
|
|
2605
2762
|
compactions: rot.compactions,
|
|
2606
2763
|
...(rec.resumeToken ? { conversation: rec.resumeToken } : {}),
|
|
2764
|
+
...(o.auto ? { auto: true } : {}),
|
|
2607
2765
|
};
|
|
2608
2766
|
let kept = "";
|
|
2609
2767
|
const before = readRotation(files);
|
|
@@ -2805,7 +2963,7 @@ export async function markInterrupted(
|
|
|
2805
2963
|
if (resumeRefusal(rec)) continue;
|
|
2806
2964
|
try {
|
|
2807
2965
|
// den#4: a rotated one relaunches from its handoff instead.
|
|
2808
|
-
const r =
|
|
2966
|
+
const r = rotationOf(d, rec)?.ready
|
|
2809
2967
|
? await relaunchAgent(d, rec.id)
|
|
2810
2968
|
: await resumeAgent(d, rec.id);
|
|
2811
2969
|
if (r.early) found.failed.push({ agent: rec.id, why: "exited at once" });
|
|
@@ -2837,7 +2995,7 @@ export async function resumeInterrupted(
|
|
|
2837
2995
|
const resumed: Resumed[] = [];
|
|
2838
2996
|
const failed: { agent: string; why: string }[] = [];
|
|
2839
2997
|
for (const rec of recs) {
|
|
2840
|
-
const rot =
|
|
2998
|
+
const rot = rotationOf(d, rec);
|
|
2841
2999
|
const why =
|
|
2842
3000
|
resumeRefusal(rec) ?? (rot?.ready ? rotatedRefusal(rec, rot) : null);
|
|
2843
3001
|
if (why) {
|
|
@@ -3019,3 +3177,279 @@ export async function startPlainSession(
|
|
|
3019
3177
|
return { info };
|
|
3020
3178
|
});
|
|
3021
3179
|
}
|
|
3180
|
+
|
|
3181
|
+
// ── D28: rotation by itself (on_due auto), and the callout's actions ────────
|
|
3182
|
+
|
|
3183
|
+
/** How often the server looks at due agents. */
|
|
3184
|
+
export const ROTATION_TICK_MS = 15_000;
|
|
3185
|
+
/** Between two asks for the handoff, at least this long. */
|
|
3186
|
+
export const ROTATION_ASK_GAP_MS = 120_000;
|
|
3187
|
+
/** Asks before auto falls back to ask. */
|
|
3188
|
+
export const ROTATION_MAX_ASKS = 2;
|
|
3189
|
+
|
|
3190
|
+
export type RotationEvent =
|
|
3191
|
+
| { kind: "asked"; agent: string; workspace: string; asked: number }
|
|
3192
|
+
| { kind: "fell-back"; agent: string; workspace: string }
|
|
3193
|
+
| {
|
|
3194
|
+
kind: "relaunched";
|
|
3195
|
+
agent: string;
|
|
3196
|
+
workspace: string;
|
|
3197
|
+
why: string;
|
|
3198
|
+
/** The conversation that ended (kept). */
|
|
3199
|
+
old?: string;
|
|
3200
|
+
}
|
|
3201
|
+
| { kind: "failed"; agent: string; workspace: string; why: string };
|
|
3202
|
+
|
|
3203
|
+
/**
|
|
3204
|
+
* D28, one look at every running agent whose rotation is due. Each one
|
|
3205
|
+
* whose settings say `auto`, at idle (never mid-turn; unknown counts as
|
|
3206
|
+
* busy): its handoff passes, so Den relaunches it; or Den asks it for the
|
|
3207
|
+
* handoff (at most ROTATION_MAX_ASKS times, ROTATION_ASK_GAP_MS apart),
|
|
3208
|
+
* then falls back to ask. The limit the hook counts against follows the
|
|
3209
|
+
* settings. Never throws: each agent's failure is an event.
|
|
3210
|
+
*/
|
|
3211
|
+
export async function rotationTick(
|
|
3212
|
+
d: AgentsResolved,
|
|
3213
|
+
now: Date = new Date(),
|
|
3214
|
+
o: { askGapMs?: number; send?: typeof sendFirstInstruction } = {},
|
|
3215
|
+
): Promise<RotationEvent[]> {
|
|
3216
|
+
const events: RotationEvent[] = [];
|
|
3217
|
+
let sessions: SessionInfo[];
|
|
3218
|
+
try {
|
|
3219
|
+
sessions = await hostSessions(d);
|
|
3220
|
+
} catch {
|
|
3221
|
+
return events;
|
|
3222
|
+
}
|
|
3223
|
+
const policyOf = policyResolver(d);
|
|
3224
|
+
const gap = o.askGapMs ?? ROTATION_ASK_GAP_MS;
|
|
3225
|
+
const send = o.send ?? sendFirstInstruction;
|
|
3226
|
+
for (const rec of listRecords(d.stateDir())) {
|
|
3227
|
+
const who = { agent: rec.id, workspace: rec.workspace };
|
|
3228
|
+
try {
|
|
3229
|
+
const s = rec.session ? sessionOf(sessions, rec) : undefined;
|
|
3230
|
+
if (!s || s.exited) continue;
|
|
3231
|
+
const pol = policyOf(rec);
|
|
3232
|
+
if (!pol) continue;
|
|
3233
|
+
const files = agentFilesDir(d.stateDir(), rec.id);
|
|
3234
|
+
const st = readRotation(files);
|
|
3235
|
+
if (!st) continue;
|
|
3236
|
+
if (
|
|
3237
|
+
st.conversation &&
|
|
3238
|
+
rec.resumeToken &&
|
|
3239
|
+
st.conversation !== rec.resumeToken
|
|
3240
|
+
)
|
|
3241
|
+
continue;
|
|
3242
|
+
syncAfter(files, pol.afterCompactions);
|
|
3243
|
+
const rot = rotationInfo(d.stateDir(), rec, pol);
|
|
3244
|
+
if (!rot?.due) continue;
|
|
3245
|
+
// Relaunch now, asked before the handoff, relaunches whatever on_due says.
|
|
3246
|
+
const wanted = rot.relaunchWanted === true;
|
|
3247
|
+
if (!wanted && (pol.onDue !== "auto" || rot.fellBack)) continue;
|
|
3248
|
+
if ((await agentBusy(d, rec)) !== false) continue;
|
|
3249
|
+
if (rot.handoff === "ok") {
|
|
3250
|
+
const r = await relaunchAgent(d, rec.id, rec.workspace, {
|
|
3251
|
+
auto: true,
|
|
3252
|
+
});
|
|
3253
|
+
events.push({
|
|
3254
|
+
kind: "relaunched",
|
|
3255
|
+
...who,
|
|
3256
|
+
why: r.why,
|
|
3257
|
+
...(rec.resumeToken ? { old: rec.resumeToken } : {}),
|
|
3258
|
+
});
|
|
3259
|
+
continue;
|
|
3260
|
+
}
|
|
3261
|
+
const last = st.askedAt ? Date.parse(st.askedAt) : 0;
|
|
3262
|
+
if (now.getTime() - last < gap) continue;
|
|
3263
|
+
if (wanted) continue; // asked once, by the person: no more asks
|
|
3264
|
+
if ((st.asked ?? 0) >= ROTATION_MAX_ASKS) {
|
|
3265
|
+
noteFellBack(files);
|
|
3266
|
+
events.push({ kind: "fell-back", ...who });
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3269
|
+
const text = rotationInstruction(files, rot.due, rot.compactions);
|
|
3270
|
+
if (
|
|
3271
|
+
await send(d, s.ref.id, rec.runtime, text, {
|
|
3272
|
+
quietMs: 500,
|
|
3273
|
+
maxMs: 5000,
|
|
3274
|
+
})
|
|
3275
|
+
) {
|
|
3276
|
+
const n = noteAsked(files, now);
|
|
3277
|
+
events.push({ kind: "asked", ...who, asked: n?.asked ?? 1 });
|
|
3278
|
+
}
|
|
3279
|
+
} catch (e) {
|
|
3280
|
+
events.push({ kind: "failed", ...who, why: (e as Error).message });
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
return events;
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
/**
|
|
3287
|
+
* D28 "Keep this conversation": its count starts again, nothing is due.
|
|
3288
|
+
* `never`: also this agent's override after_compactions never ("don't ask
|
|
3289
|
+
* again for this agent").
|
|
3290
|
+
*/
|
|
3291
|
+
export function keepAgentConversation(
|
|
3292
|
+
d: AgentsResolved,
|
|
3293
|
+
id: string,
|
|
3294
|
+
ws?: string,
|
|
3295
|
+
o: { never?: boolean } = {},
|
|
3296
|
+
): AgentRecord {
|
|
3297
|
+
const rec = agentRecord(d, id, ws);
|
|
3298
|
+
if (!keepConversation(agentFilesDir(d.stateDir(), rec.id)))
|
|
3299
|
+
throw refused(`agent ${rec.id} has no rotation to reset`);
|
|
3300
|
+
if (o.never)
|
|
3301
|
+
setRotateValue(d.stateDir(), "agent", rec.id, "after_compactions", "never");
|
|
3302
|
+
return rec;
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
/**
|
|
3306
|
+
* D28 "Write handoff": types the handoff instruction into agent `id`'s
|
|
3307
|
+
* running session, at idle. Refused while it is mid-turn or not running.
|
|
3308
|
+
*/
|
|
3309
|
+
export async function askForHandoff(
|
|
3310
|
+
d: AgentsResolved,
|
|
3311
|
+
id: string,
|
|
3312
|
+
ws?: string,
|
|
3313
|
+
send: typeof sendFirstInstruction = sendFirstInstruction,
|
|
3314
|
+
/** Relaunch now: Den relaunches it once the handoff passes. */
|
|
3315
|
+
o: { relaunch?: boolean } = {},
|
|
3316
|
+
): Promise<AgentRecord & { resumed?: boolean }> {
|
|
3317
|
+
const rec = agentRecord(d, id, ws);
|
|
3318
|
+
const s = sessionOf(await hostSessions(d), rec);
|
|
3319
|
+
if (!s || s.exited) {
|
|
3320
|
+
// Stopped, no handoff yet: its conversation resumes to write one (the
|
|
3321
|
+
// resume hook gives it the instruction), then Den relaunches it.
|
|
3322
|
+
const files = agentFilesDir(d.stateDir(), rec.id);
|
|
3323
|
+
if (!o.relaunch)
|
|
3324
|
+
throw refused(
|
|
3325
|
+
`agent ${rec.id} is not running: resume it to write its handoff`,
|
|
3326
|
+
[`Resume: ${d.cli} agents resume ${rec.id}`],
|
|
3327
|
+
);
|
|
3328
|
+
const r = await resumeAgent(d, rec.id, ws);
|
|
3329
|
+
noteAsked(files, new Date(), true);
|
|
3330
|
+
return { ...r.rec, resumed: true };
|
|
3331
|
+
}
|
|
3332
|
+
if ((await agentBusy(d, rec)) === true)
|
|
3333
|
+
throw refused(`agent ${rec.id} is mid-turn: ask again when its turn ends`);
|
|
3334
|
+
const rot = rotationOf(d, rec);
|
|
3335
|
+
const files = agentFilesDir(d.stateDir(), rec.id);
|
|
3336
|
+
const text = rotationInstruction(
|
|
3337
|
+
files,
|
|
3338
|
+
rot?.due ?? "compactions",
|
|
3339
|
+
rot?.compactions ?? readRotation(files)?.compactions ?? 0,
|
|
3340
|
+
);
|
|
3341
|
+
if (
|
|
3342
|
+
!(await send(d, s.ref.id, rec.runtime, text, { quietMs: 500, maxMs: 5000 }))
|
|
3343
|
+
)
|
|
3344
|
+
throw refused(`agent ${rec.id}'s session did not take the prompt`);
|
|
3345
|
+
noteAsked(files, new Date(), o.relaunch === true);
|
|
3346
|
+
return rec;
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
/**
|
|
3350
|
+
* D28: Resume (or Start) on an agent follows its on_due: a due agent whose
|
|
3351
|
+
* handoff passes relaunches from it under auto; ask refuses with the
|
|
3352
|
+
* callout's choices (code "rotated"); off, or nothing due, resumes.
|
|
3353
|
+
*/
|
|
3354
|
+
export async function resumeOrRelaunch(
|
|
3355
|
+
d: AgentsResolved,
|
|
3356
|
+
id: string,
|
|
3357
|
+
ws?: string,
|
|
3358
|
+
): Promise<
|
|
3359
|
+
{ kind: "resumed"; s: Resumed } | { kind: "relaunched"; s: Relaunched }
|
|
3360
|
+
> {
|
|
3361
|
+
const rec = agentRecord(d, id, ws);
|
|
3362
|
+
const rot = rotationOf(d, rec);
|
|
3363
|
+
if (rot?.ready && rot.onDue === "auto")
|
|
3364
|
+
return {
|
|
3365
|
+
kind: "relaunched",
|
|
3366
|
+
s: await relaunchAgent(d, id, ws, { auto: true }),
|
|
3367
|
+
};
|
|
3368
|
+
return { kind: "resumed", s: await resumeAgent(d, id, ws) };
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/** D28: the rotation settings, and agent `agent`'s effective values. */
|
|
3372
|
+
export function rotationSettings(
|
|
3373
|
+
d: AgentsResolved,
|
|
3374
|
+
agent?: string,
|
|
3375
|
+
ws?: string,
|
|
3376
|
+
): {
|
|
3377
|
+
defaults: typeof ROTATE_DEFAULTS;
|
|
3378
|
+
org: OrgRotate;
|
|
3379
|
+
install: RotateValues;
|
|
3380
|
+
overrides: NonNullable<RotateFile["overrides"]>;
|
|
3381
|
+
effective?: RotationPolicy & { agent: string };
|
|
3382
|
+
} {
|
|
3383
|
+
const f = readRotateFile(d.stateDir());
|
|
3384
|
+
let org: OrgRotate = {};
|
|
3385
|
+
try {
|
|
3386
|
+
org = d.agentsConfig().rotateOrg ?? {};
|
|
3387
|
+
} catch {
|
|
3388
|
+
org = {};
|
|
3389
|
+
}
|
|
3390
|
+
let effective: (RotationPolicy & { agent: string }) | undefined;
|
|
3391
|
+
if (agent) {
|
|
3392
|
+
const rec = agentRecord(d, agent, ws);
|
|
3393
|
+
const pol = policyResolver(d)(rec);
|
|
3394
|
+
if (pol) effective = { agent: rec.id, ...pol };
|
|
3395
|
+
}
|
|
3396
|
+
return {
|
|
3397
|
+
defaults: ROTATE_DEFAULTS,
|
|
3398
|
+
org,
|
|
3399
|
+
install: f.install ?? {},
|
|
3400
|
+
overrides: f.overrides ?? {},
|
|
3401
|
+
...(effective ? { effective } : {}),
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
/**
|
|
3406
|
+
* D28: set one rotation setting from a request body `{ scope, target?,
|
|
3407
|
+
* key, value }` (value null: cleared). A target is checked to be a name
|
|
3408
|
+
* (persona-workspace: `<persona>@<workspace>`).
|
|
3409
|
+
*/
|
|
3410
|
+
export function setRotationSetting(
|
|
3411
|
+
d: AgentsResolved,
|
|
3412
|
+
body: Record<string, unknown>,
|
|
3413
|
+
): { file: RotateFile } {
|
|
3414
|
+
const scope = body.scope;
|
|
3415
|
+
if (
|
|
3416
|
+
scope !== "install" &&
|
|
3417
|
+
!(OVERRIDE_SCOPES as readonly unknown[]).includes(scope)
|
|
3418
|
+
)
|
|
3419
|
+
throw usage(
|
|
3420
|
+
`scope: one of install, ${OVERRIDE_SCOPES.join(", ")} (got ${JSON.stringify(scope)})`,
|
|
3421
|
+
);
|
|
3422
|
+
const target = body.target;
|
|
3423
|
+
if (scope !== "install") {
|
|
3424
|
+
const ok =
|
|
3425
|
+
typeof target === "string" &&
|
|
3426
|
+
(scope === "persona-workspace"
|
|
3427
|
+
? /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}@[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(
|
|
3428
|
+
target,
|
|
3429
|
+
)
|
|
3430
|
+
: scope === "service"
|
|
3431
|
+
? /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(target)
|
|
3432
|
+
: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(target));
|
|
3433
|
+
if (!ok)
|
|
3434
|
+
throw usage(
|
|
3435
|
+
scope === "persona-workspace"
|
|
3436
|
+
? "target: <persona>@<workspace>"
|
|
3437
|
+
: `target: the ${String(scope)} it is for`,
|
|
3438
|
+
);
|
|
3439
|
+
} else if (target !== undefined && target !== null)
|
|
3440
|
+
throw usage("target: install takes none");
|
|
3441
|
+
if (typeof body.key !== "string") throw usage("key is required");
|
|
3442
|
+
try {
|
|
3443
|
+
return {
|
|
3444
|
+
file: setRotateValue(
|
|
3445
|
+
d.stateDir(),
|
|
3446
|
+
scope as OverrideScope | "install",
|
|
3447
|
+
scope === "install" ? undefined : (target as string),
|
|
3448
|
+
body.key,
|
|
3449
|
+
body.value === null ? undefined : body.value,
|
|
3450
|
+
),
|
|
3451
|
+
};
|
|
3452
|
+
} catch (e) {
|
|
3453
|
+
throw usage((e as Error).message);
|
|
3454
|
+
}
|
|
3455
|
+
}
|