@sorenllm/opencode-forge 0.3.4 → 0.4.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 +119 -138
- package/dist/index.js +482 -1608
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -12335,7 +12335,7 @@ function tool(input) {
|
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
12337
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12338
|
-
import { appendFileSync, existsSync as existsSync3, mkdirSync as
|
|
12338
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, statSync as statSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
12339
12339
|
import { isAbsolute, join as join5, relative } from "node:path";
|
|
12340
12340
|
import { tmpdir as tmpdir3, homedir } from "node:os";
|
|
12341
12341
|
|
|
@@ -14723,15 +14723,20 @@ function createLocator(platform = process.platform, posix = {}, win = {}) {
|
|
|
14723
14723
|
return platform === "win32" ? createWindowsLocator(win) : createPosixLocator(posix);
|
|
14724
14724
|
}
|
|
14725
14725
|
|
|
14726
|
-
// src/
|
|
14727
|
-
|
|
14728
|
-
|
|
14729
|
-
|
|
14730
|
-
|
|
14731
|
-
|
|
14732
|
-
|
|
14733
|
-
|
|
14726
|
+
// src/models-dev.ts
|
|
14727
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
14728
|
+
import { join as join4 } from "node:path";
|
|
14729
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
14730
|
+
var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
|
|
14731
|
+
function parseIdentity(identity) {
|
|
14732
|
+
const slash = identity.indexOf("/");
|
|
14733
|
+
if (slash <= 0 || slash === identity.length - 1)
|
|
14734
|
+
return null;
|
|
14735
|
+
return { provider: identity.slice(0, slash), model: identity.slice(slash + 1) };
|
|
14736
|
+
}
|
|
14734
14737
|
function nativeLadder(catalog, identity) {
|
|
14738
|
+
if (!catalog)
|
|
14739
|
+
return null;
|
|
14735
14740
|
const parsed = parseIdentity(identity);
|
|
14736
14741
|
if (!parsed)
|
|
14737
14742
|
return null;
|
|
@@ -14741,352 +14746,174 @@ function nativeLadder(catalog, identity) {
|
|
|
14741
14746
|
const options = entry.reasoningOptions;
|
|
14742
14747
|
return Array.isArray(options) ? [...options] : null;
|
|
14743
14748
|
}
|
|
14744
|
-
function
|
|
14745
|
-
|
|
14746
|
-
if (!parsed)
|
|
14747
|
-
return;
|
|
14748
|
-
return catalog.providers?.[parsed.provider]?.models?.[parsed.model]?.cost;
|
|
14749
|
-
}
|
|
14750
|
-
function parseIdentity(identity) {
|
|
14751
|
-
const slash = identity.indexOf("/");
|
|
14752
|
-
if (slash <= 0 || slash === identity.length - 1)
|
|
14753
|
-
return null;
|
|
14754
|
-
return { provider: identity.slice(0, slash), model: identity.slice(slash + 1) };
|
|
14755
|
-
}
|
|
14756
|
-
function catalogKnowsIdentity(catalog, identity) {
|
|
14757
|
-
const parsed = parseIdentity(identity);
|
|
14758
|
-
if (!parsed)
|
|
14759
|
-
return false;
|
|
14760
|
-
return Boolean(catalog.providers?.[parsed.provider]?.models?.[parsed.model]);
|
|
14749
|
+
function modelsDevCacheDir(base) {
|
|
14750
|
+
return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
|
|
14761
14751
|
}
|
|
14762
|
-
function
|
|
14763
|
-
const
|
|
14764
|
-
const
|
|
14765
|
-
|
|
14766
|
-
|
|
14767
|
-
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
|
|
14771
|
-
findings.push({
|
|
14772
|
-
level: "warn",
|
|
14773
|
-
code: "tier-id-charset",
|
|
14774
|
-
message: `tier id "${id}" violates [a-z0-9-] (it materializes as forge-<tier> agent ids)`
|
|
14775
|
-
});
|
|
14776
|
-
}
|
|
14777
|
-
const shape = def.shape ?? "readonly";
|
|
14778
|
-
if (def.shape === undefined) {
|
|
14779
|
-
findings.push({
|
|
14780
|
-
level: "warn",
|
|
14781
|
-
code: "shape-defaulted",
|
|
14782
|
-
message: `custom tier "${id}" has no shape; defaulting to readonly (write requires explicit "shape": "write")`
|
|
14783
|
-
});
|
|
14784
|
-
}
|
|
14785
|
-
tiers[id] = { ...def, shape };
|
|
14786
|
-
}
|
|
14787
|
-
const roster = [];
|
|
14788
|
-
for (const entry of opts.userRoster ?? []) {
|
|
14789
|
-
const ladder = nativeLadder(catalog, entry.model);
|
|
14790
|
-
const verified = ladder !== null;
|
|
14791
|
-
if (verified && catalogKnowsIdentity(catalog, entry.model)) {
|
|
14792
|
-
const legal = ladder;
|
|
14793
|
-
if (legal.length === 0) {
|
|
14794
|
-
findings.push({
|
|
14795
|
-
level: "notice",
|
|
14796
|
-
code: "unnamed-levels-accepted",
|
|
14797
|
-
message: `roster entry ${entry.model} has no cataloged named reasoning levels (toggle/budget model); expose accepted verbatim without validation`
|
|
14798
|
-
});
|
|
14799
|
-
roster.push({ model: entry.model, expose: [...entry.expose ?? []], profiles: [...entry.profiles], verified: true });
|
|
14800
|
-
} else {
|
|
14801
|
-
const expose = entry.expose ?? legal;
|
|
14802
|
-
const illegal = expose.filter((d) => !legal.includes(d));
|
|
14803
|
-
if (illegal.length > 0) {
|
|
14804
|
-
findings.push({
|
|
14805
|
-
level: "error",
|
|
14806
|
-
code: "expose-unknown-level",
|
|
14807
|
-
message: `roster entry ${entry.model} exposes unknown level(s) ${illegal.map((d) => `"${d}"`).join(", ")}; legal ladder: ${legal.join(", ")}`
|
|
14808
|
-
});
|
|
14809
|
-
}
|
|
14810
|
-
roster.push({
|
|
14811
|
-
model: entry.model,
|
|
14812
|
-
expose: expose.filter((d) => legal.includes(d)),
|
|
14813
|
-
profiles: [...entry.profiles],
|
|
14814
|
-
verified: true
|
|
14815
|
-
});
|
|
14752
|
+
function namedLevels(rows) {
|
|
14753
|
+
const out = [];
|
|
14754
|
+
for (const r of rows) {
|
|
14755
|
+
if (r && typeof r === "object" && r.type === "effort") {
|
|
14756
|
+
const values = r.values;
|
|
14757
|
+
if (Array.isArray(values)) {
|
|
14758
|
+
for (const v of values)
|
|
14759
|
+
if (typeof v === "string")
|
|
14760
|
+
out.push(v);
|
|
14816
14761
|
}
|
|
14817
|
-
} else {
|
|
14818
|
-
findings.push({
|
|
14819
|
-
level: "notice",
|
|
14820
|
-
code: "unverified-identity",
|
|
14821
|
-
message: `roster entry ${entry.model} is not in the models.dev snapshot; expose accepted verbatim without validation`
|
|
14822
|
-
});
|
|
14823
|
-
roster.push({ model: entry.model, expose: [...entry.expose ?? []], profiles: [...entry.profiles], verified: false });
|
|
14824
14762
|
}
|
|
14825
14763
|
}
|
|
14826
|
-
|
|
14827
|
-
|
|
14828
|
-
|
|
14829
|
-
|
|
14830
|
-
|
|
14831
|
-
|
|
14832
|
-
|
|
14833
|
-
|
|
14834
|
-
|
|
14835
|
-
|
|
14836
|
-
if (unknownTiers.length > 0) {
|
|
14837
|
-
findings.push({
|
|
14838
|
-
level: "warn",
|
|
14839
|
-
code: "unknown-tier-ref",
|
|
14840
|
-
message: `roster entry ${entry.model} references nonexistent tier(s): ${unknownTiers.join(", ")}`
|
|
14841
|
-
});
|
|
14842
|
-
}
|
|
14843
|
-
}
|
|
14844
|
-
for (const [id, tier] of Object.entries(tiers)) {
|
|
14845
|
-
if (tier.defaultDepth === undefined)
|
|
14764
|
+
return out;
|
|
14765
|
+
}
|
|
14766
|
+
function reduceModelsDev(raw) {
|
|
14767
|
+
const catalog = { providers: {} };
|
|
14768
|
+
const providers = raw?.constructor === Object ? raw : null;
|
|
14769
|
+
if (!providers)
|
|
14770
|
+
return catalog;
|
|
14771
|
+
for (const [providerID, provider] of Object.entries(providers)) {
|
|
14772
|
+
const models = provider.models;
|
|
14773
|
+
if (!models || typeof models !== "object")
|
|
14846
14774
|
continue;
|
|
14847
|
-
const
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14854
|
-
|
|
14775
|
+
const out = { models: {} };
|
|
14776
|
+
for (const [modelID, model] of Object.entries(models)) {
|
|
14777
|
+
const m = model;
|
|
14778
|
+
const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
|
|
14779
|
+
const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
|
|
14780
|
+
out.models[modelID] = {
|
|
14781
|
+
...options ? { reasoningOptions: options } : {},
|
|
14782
|
+
...cost ? { cost } : {}
|
|
14783
|
+
};
|
|
14855
14784
|
}
|
|
14785
|
+
catalog.providers[providerID] = out;
|
|
14856
14786
|
}
|
|
14857
|
-
return
|
|
14787
|
+
return catalog;
|
|
14858
14788
|
}
|
|
14859
|
-
|
|
14860
|
-
|
|
14861
|
-
|
|
14862
|
-
|
|
14863
|
-
|
|
14864
|
-
|
|
14865
|
-
|
|
14866
|
-
|
|
14867
|
-
|
|
14868
|
-
|
|
14869
|
-
|
|
14789
|
+
async function loadModelsDevSnapshot(opts = {}) {
|
|
14790
|
+
const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
|
|
14791
|
+
const cacheDir = modelsDevCacheDir(opts.cacheDir);
|
|
14792
|
+
const cacheFile = join4(cacheDir, "snapshot.json");
|
|
14793
|
+
const fetcher = opts.fetcher;
|
|
14794
|
+
if (fetcher) {
|
|
14795
|
+
try {
|
|
14796
|
+
const res = await fetcher(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
14797
|
+
if (res.ok)
|
|
14798
|
+
return { catalog: reduceModelsDev(JSON.parse(await res.text())), degraded: false, source: url2 };
|
|
14799
|
+
} catch {}
|
|
14800
|
+
return { catalog: { providers: {} }, degraded: true, source: "fetch-failed" };
|
|
14870
14801
|
}
|
|
14871
|
-
|
|
14802
|
+
try {
|
|
14803
|
+
const res = await fetch(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
14804
|
+
if (res.ok) {
|
|
14805
|
+
const catalog = reduceModelsDev(JSON.parse(await res.text()));
|
|
14806
|
+
try {
|
|
14807
|
+
mkdirSync4(cacheDir, { recursive: true });
|
|
14808
|
+
writeFileSync4(cacheFile, JSON.stringify(catalog));
|
|
14809
|
+
} catch {}
|
|
14810
|
+
return { catalog, degraded: false, source: url2 };
|
|
14811
|
+
}
|
|
14812
|
+
} catch {}
|
|
14813
|
+
try {
|
|
14814
|
+
const cached2 = JSON.parse(readFileSync6(cacheFile, "utf8"));
|
|
14815
|
+
if (cached2 && typeof cached2 === "object" && cached2.providers)
|
|
14816
|
+
return { catalog: cached2, degraded: true, source: "cache" };
|
|
14817
|
+
} catch {}
|
|
14818
|
+
return { catalog: { providers: {} }, degraded: true, source: "unavailable" };
|
|
14872
14819
|
}
|
|
14873
|
-
|
|
14874
|
-
|
|
14875
|
-
|
|
14876
|
-
|
|
14877
|
-
total.output += t.output ?? 0;
|
|
14878
|
-
total.reasoning += t.reasoning ?? 0;
|
|
14879
|
-
total.cache.read += t.cache?.read ?? 0;
|
|
14880
|
-
total.cache.write += t.cache?.write ?? 0;
|
|
14881
|
-
}
|
|
14882
|
-
return total;
|
|
14820
|
+
|
|
14821
|
+
// src/crew-gate.ts
|
|
14822
|
+
function normTitle(t) {
|
|
14823
|
+
return String(t ?? "").trim().toLowerCase();
|
|
14883
14824
|
}
|
|
14884
|
-
function
|
|
14885
|
-
|
|
14886
|
-
|
|
14825
|
+
function validateCrewReport(report, declared) {
|
|
14826
|
+
const gaps = [];
|
|
14827
|
+
if (!Array.isArray(report) || report.length === 0) {
|
|
14828
|
+
return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every declared subtask"] };
|
|
14887
14829
|
}
|
|
14888
|
-
|
|
14889
|
-
|
|
14830
|
+
const declaredTitles = new Set;
|
|
14831
|
+
for (const s of Array.isArray(declared) ? declared : []) {
|
|
14832
|
+
const t = normTitle(s?.title);
|
|
14833
|
+
if (t)
|
|
14834
|
+
declaredTitles.add(t);
|
|
14890
14835
|
}
|
|
14891
|
-
|
|
14892
|
-
|
|
14836
|
+
const covered = new Set;
|
|
14837
|
+
report.forEach((sub, i) => {
|
|
14838
|
+
const label = sub?.title?.trim() || `subtask #${i + 1}`;
|
|
14839
|
+
const key = normTitle(sub?.title);
|
|
14840
|
+
if (!sub?.title?.trim())
|
|
14841
|
+
gaps.push(`${label}: missing title`);
|
|
14842
|
+
else if (covered.has(key))
|
|
14843
|
+
gaps.push(`${label}: duplicate report entry for the same title`);
|
|
14844
|
+
covered.add(key);
|
|
14845
|
+
const verdict = String(sub?.verdict ?? "").toUpperCase();
|
|
14846
|
+
if (verdict !== "PASS" && verdict !== "FAIL")
|
|
14847
|
+
gaps.push(`${label}: verdict must be PASS or FAIL (got "${sub?.verdict ?? ""}")`);
|
|
14848
|
+
if (!String(sub?.evidence ?? "").trim())
|
|
14849
|
+
gaps.push(`${label}: missing evidence statement`);
|
|
14850
|
+
if (verdict === "FAIL" && (!Array.isArray(sub?.attempts) || sub.attempts.length < 2)) {
|
|
14851
|
+
gaps.push(`${label}: FAIL requires both failure reports (the attempt and the one retry) in attempts[]`);
|
|
14852
|
+
}
|
|
14853
|
+
if (key && declaredTitles.size > 0 && !declaredTitles.has(key)) {
|
|
14854
|
+
gaps.push(`${label}: not part of the plan registered at crew_begin (renegade work) — fold discoveries into an existing subtask's verdict notes, or discard and re-crew with the full plan`);
|
|
14855
|
+
}
|
|
14856
|
+
});
|
|
14857
|
+
for (const t of declaredTitles) {
|
|
14858
|
+
if (!covered.has(t)) {
|
|
14859
|
+
const original = (Array.isArray(declared) ? declared : []).find((s) => normTitle(s?.title) === t)?.title ?? t;
|
|
14860
|
+
gaps.push(`"${original}": declared at crew_begin but has no verdict in the report — a declared subtask was silently dropped`);
|
|
14861
|
+
}
|
|
14893
14862
|
}
|
|
14894
|
-
|
|
14895
|
-
return { costUsd: usd };
|
|
14863
|
+
return { ok: gaps.length === 0, gaps };
|
|
14896
14864
|
}
|
|
14897
14865
|
|
|
14898
14866
|
// src/dispatch-prompt.ts
|
|
14899
|
-
var
|
|
14867
|
+
var WORKER_MANDATES = [
|
|
14900
14868
|
"Mandates (non-negotiable):",
|
|
14901
14869
|
"- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
|
|
14902
14870
|
"- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
|
|
14903
14871
|
"- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
|
|
14904
14872
|
].join(`
|
|
14905
14873
|
`);
|
|
14906
|
-
var READONLY_DISCIPLINE = `You are a readonly
|
|
14907
|
-
var WRITE_DISCIPLINE = `You are
|
|
14908
|
-
function
|
|
14909
|
-
const
|
|
14910
|
-
return [
|
|
14911
|
-
"[forge:dispatch] You were dispatched by the forge main agent as a scoped worker.",
|
|
14912
|
-
discipline,
|
|
14913
|
-
...input.role ? ["", "Your role:", input.role.trim()] : [],
|
|
14914
|
-
"",
|
|
14915
|
-
"Task:",
|
|
14916
|
-
input.prompt.trim(),
|
|
14917
|
-
"",
|
|
14918
|
-
COMMON_MANDATES,
|
|
14919
|
-
"",
|
|
14920
|
-
"Finish with a single concluding report (the main agent will read only this): what you did, what you found/produced, and the evidence."
|
|
14921
|
-
].join(`
|
|
14874
|
+
var READONLY_DISCIPLINE = (agent) => `You are forge-${agent}, a readonly worker: you gather, read, run read-only checks, and REPORT. Your restrictions are the design, not obstacles — do not attempt to work around them; report what you found instead.`;
|
|
14875
|
+
var WRITE_DISCIPLINE = (agent) => `You are forge-${agent}, a worker executing scoped tasks handed to you through the task tool. Do exactly the task, nothing else: no refactoring beyond scope, no unrelated files, no side quests.`;
|
|
14876
|
+
function materializeAgentPrompt(input) {
|
|
14877
|
+
const frame = input.shape === "readonly" ? READONLY_DISCIPLINE(input.agent) : WRITE_DISCIPLINE(input.agent);
|
|
14878
|
+
return [frame, "", "Your role:", input.role.trim(), "", WORKER_MANDATES].join(`
|
|
14922
14879
|
`);
|
|
14923
14880
|
}
|
|
14924
14881
|
|
|
14925
|
-
// src/dispatch-
|
|
14926
|
-
|
|
14927
|
-
|
|
14928
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
14931
|
-
|
|
14932
|
-
|
|
14933
|
-
|
|
14934
|
-
|
|
14935
|
-
for (const entry of cfg.roster) {
|
|
14936
|
-
if (!entry.profiles.includes(tierId))
|
|
14937
|
-
continue;
|
|
14938
|
-
let reason;
|
|
14939
|
-
if (exclude.includes(entry.model))
|
|
14940
|
-
reason = "excluded after a failed attempt";
|
|
14941
|
-
else if (!available(entry.model))
|
|
14942
|
-
reason = "identity currently unavailable";
|
|
14943
|
-
else if (depth !== undefined && !entry.expose.includes(depth))
|
|
14944
|
-
reason = `depth "${depth}" not exposed`;
|
|
14945
|
-
else
|
|
14946
|
-
reason = "eligible";
|
|
14947
|
-
lines.push({ identity: entry.model, expose: [...entry.expose], verified: entry.verified, reason });
|
|
14948
|
-
}
|
|
14949
|
-
return lines;
|
|
14950
|
-
}
|
|
14951
|
-
function costRank(catalog, identity) {
|
|
14952
|
-
const cost = identityCost(catalog, identity);
|
|
14953
|
-
if (!cost || typeof cost.input !== "number" || typeof cost.output !== "number")
|
|
14954
|
-
return Number.POSITIVE_INFINITY;
|
|
14955
|
-
return cost.input + cost.output;
|
|
14956
|
-
}
|
|
14957
|
-
function resolveDispatch(cfg, req, available, catalog = { providers: {} }) {
|
|
14958
|
-
const tier = cfg.tiers[req.profile];
|
|
14959
|
-
if (!tier) {
|
|
14960
|
-
return {
|
|
14961
|
-
ok: false,
|
|
14962
|
-
error: {
|
|
14963
|
-
code: "unknown-tier",
|
|
14964
|
-
message: `unknown tier "${req.profile}"; known tiers: ${Object.keys(cfg.tiers).join(", ")}`,
|
|
14965
|
-
menu: [],
|
|
14966
|
-
vocabulary: exposedVocabulary(cfg)
|
|
14967
|
-
}
|
|
14968
|
-
};
|
|
14969
|
-
}
|
|
14970
|
-
const exclude = req.exclude ?? [];
|
|
14971
|
-
if (tier.model) {
|
|
14972
|
-
const pinnedIdentity = tier.model;
|
|
14973
|
-
const entry = cfg.roster.find((e) => e.model === pinnedIdentity);
|
|
14974
|
-
const expose = entry?.expose ?? [];
|
|
14975
|
-
const menu = [
|
|
14976
|
-
{ identity: pinnedIdentity, expose, verified: entry?.verified ?? false, reason: "pinned tier target" }
|
|
14977
|
-
];
|
|
14978
|
-
if (!available(pinnedIdentity)) {
|
|
14979
|
-
return {
|
|
14980
|
-
ok: false,
|
|
14981
|
-
error: {
|
|
14982
|
-
code: "pin-unavailable",
|
|
14983
|
-
message: `tier "${req.profile}" is pinned to ${pinnedIdentity}, which is currently unavailable; fix or unpin the tier — no fallback model will be used`,
|
|
14984
|
-
menu,
|
|
14985
|
-
vocabulary: exposedVocabulary(cfg)
|
|
14986
|
-
}
|
|
14987
|
-
};
|
|
14988
|
-
}
|
|
14989
|
-
const depth2 = req.depth ?? tier.depth;
|
|
14990
|
-
if (depth2 === undefined) {
|
|
14991
|
-
return {
|
|
14992
|
-
ok: false,
|
|
14993
|
-
error: {
|
|
14994
|
-
code: "depth-required",
|
|
14995
|
-
message: `tier "${req.profile}" is pinned without a depth and none was requested; pass depth explicitly`,
|
|
14996
|
-
menu,
|
|
14997
|
-
vocabulary: exposedVocabulary(cfg)
|
|
14998
|
-
}
|
|
14999
|
-
};
|
|
15000
|
-
}
|
|
15001
|
-
if (!expose.includes(depth2)) {
|
|
15002
|
-
return {
|
|
15003
|
-
ok: false,
|
|
15004
|
-
error: {
|
|
15005
|
-
code: "pin-depth-mismatch",
|
|
15006
|
-
message: `pinned model ${pinnedIdentity} does not expose depth "${depth2}" (expose: ${expose.join(", ") || "none"}); exact match only — no clamping`,
|
|
15007
|
-
menu,
|
|
15008
|
-
vocabulary: exposedVocabulary(cfg)
|
|
15009
|
-
}
|
|
15010
|
-
};
|
|
15011
|
-
}
|
|
15012
|
-
return { ok: true, identity: pinnedIdentity, depth: depth2, pinned: true };
|
|
15013
|
-
}
|
|
15014
|
-
const depth = req.depth ?? tier.defaultDepth;
|
|
15015
|
-
if (depth === undefined) {
|
|
15016
|
-
return {
|
|
15017
|
-
ok: false,
|
|
15018
|
-
error: {
|
|
15019
|
-
code: "depth-required",
|
|
15020
|
-
message: `tier "${req.profile}" has no usable default depth (its default was removed at validation); pass depth explicitly`,
|
|
15021
|
-
menu: menuFor(cfg, req.profile, undefined, exclude, available),
|
|
15022
|
-
vocabulary: exposedVocabulary(cfg)
|
|
15023
|
-
}
|
|
15024
|
-
};
|
|
15025
|
-
}
|
|
15026
|
-
const candidates = cfg.roster.map((entry, index) => ({ entry, index })).filter(({ entry }) => entry.profiles.includes(req.profile)).filter(({ entry }) => !exclude.includes(entry.model)).filter(({ entry }) => available(entry.model)).filter(({ entry }) => entry.expose.includes(depth)).sort((a, b) => a.index - b.index || costRank(catalog, a.entry.model) - costRank(catalog, b.entry.model));
|
|
15027
|
-
if (candidates.length === 0) {
|
|
15028
|
-
return {
|
|
15029
|
-
ok: false,
|
|
15030
|
-
error: {
|
|
15031
|
-
code: "no-candidate",
|
|
15032
|
-
message: `no candidate serves tier "${req.profile}" at depth "${depth}"; pick a depth from the menu's exposures (exact match — the plugin never clamps)`,
|
|
15033
|
-
menu: menuFor(cfg, req.profile, depth, exclude, available),
|
|
15034
|
-
vocabulary: exposedVocabulary(cfg)
|
|
15035
|
-
}
|
|
15036
|
-
};
|
|
15037
|
-
}
|
|
15038
|
-
return { ok: true, identity: candidates[0].entry.model, depth, pinned: false };
|
|
14882
|
+
// src/dispatch-tiers.ts
|
|
14883
|
+
var MUTATING_TOOLS = ["write", "edit", "bash"];
|
|
14884
|
+
var ROLE_PROMPTS = {
|
|
14885
|
+
research: "You are a research worker: search, read, and run read-only checks to answer the assigned question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
|
|
14886
|
+
review: "You are a review worker: read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list, each with file:line evidence and a concrete fix suggestion. End with a single-line verdict."
|
|
14887
|
+
};
|
|
14888
|
+
function rolePromptFor(agentId, def) {
|
|
14889
|
+
if (def?.prompt && def.prompt.trim().length > 0)
|
|
14890
|
+
return def.prompt;
|
|
14891
|
+
return ROLE_PROMPTS[agentId] ?? `You are a ${agentId} worker. Execute the assigned task exactly as scoped.`;
|
|
15039
14892
|
}
|
|
15040
|
-
function
|
|
15041
|
-
const
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
|
|
15045
|
-
|
|
15046
|
-
|
|
15047
|
-
|
|
15048
|
-
|
|
15049
|
-
|
|
15050
|
-
}
|
|
15051
|
-
if (!def.model || typeof def.model !== "string") {
|
|
15052
|
-
return {
|
|
15053
|
-
ok: false,
|
|
15054
|
-
error: {
|
|
15055
|
-
code: "pin-unavailable",
|
|
15056
|
-
message: `agent "${req.agent}" has no usable model (model: ${JSON.stringify(def.model)}); fix its "model" in forge.json — no fallback model will be used`
|
|
15057
|
-
}
|
|
15058
|
-
};
|
|
15059
|
-
}
|
|
15060
|
-
if (!available(def.model)) {
|
|
15061
|
-
return {
|
|
15062
|
-
ok: false,
|
|
15063
|
-
error: {
|
|
15064
|
-
code: "pin-unavailable",
|
|
15065
|
-
message: `agent "${req.agent}" is pinned to ${def.model}, which is not configured on this host; fix the agent's "model" in forge.json — no fallback model will be used`
|
|
15066
|
-
}
|
|
15067
|
-
};
|
|
15068
|
-
}
|
|
15069
|
-
const depths = Array.isArray(def.depths) ? def.depths : [];
|
|
15070
|
-
if (depths.length === 0) {
|
|
15071
|
-
return {
|
|
15072
|
-
ok: false,
|
|
15073
|
-
error: {
|
|
15074
|
-
code: "depth-not-in-set",
|
|
15075
|
-
message: `agent "${req.agent}" defines no depths; give it at least one depth in forge.json (canonical words: none, low, medium, high, max — or the model's native level names)`
|
|
15076
|
-
}
|
|
15077
|
-
};
|
|
15078
|
-
}
|
|
15079
|
-
const depth = req.depth ?? depths[0];
|
|
15080
|
-
if (!depths.includes(depth)) {
|
|
15081
|
-
return {
|
|
15082
|
-
ok: false,
|
|
15083
|
-
error: {
|
|
15084
|
-
code: "depth-not-in-set",
|
|
15085
|
-
message: `agent "${req.agent}" does not allow depth "${depth}" (allowed: ${depths.join(", ")}) — exact match only, the plugin never clamps`
|
|
15086
|
-
}
|
|
15087
|
-
};
|
|
14893
|
+
function agentDescriptionFor(agentId, def) {
|
|
14894
|
+
const shape = def.shape === "write" ? "write" : "readonly";
|
|
14895
|
+
return `forge subagent "${agentId}" (${shape}, pinned ${def.model}) — dispatch via the task tool with subagent_type "forge-${agentId}".`;
|
|
14896
|
+
}
|
|
14897
|
+
function forgeAgentDef(agentId, def) {
|
|
14898
|
+
const permission = { task: "deny" };
|
|
14899
|
+
const shape = def.shape ?? "readonly";
|
|
14900
|
+
if (shape === "readonly") {
|
|
14901
|
+
for (const t of MUTATING_TOOLS)
|
|
14902
|
+
permission[t] = "deny";
|
|
15088
14903
|
}
|
|
15089
|
-
|
|
14904
|
+
Object.assign(permission, def.permission ?? {});
|
|
14905
|
+
permission.task = "deny";
|
|
14906
|
+
return {
|
|
14907
|
+
description: agentDescriptionFor(agentId, def),
|
|
14908
|
+
mode: "subagent",
|
|
14909
|
+
model: def.model,
|
|
14910
|
+
prompt: materializeAgentPrompt({
|
|
14911
|
+
agent: agentId,
|
|
14912
|
+
shape,
|
|
14913
|
+
role: rolePromptFor(agentId, def)
|
|
14914
|
+
}),
|
|
14915
|
+
permission
|
|
14916
|
+
};
|
|
15090
14917
|
}
|
|
15091
14918
|
|
|
15092
14919
|
// src/dispatch-depth.ts
|
|
@@ -15147,671 +14974,13 @@ function applyDepthTranslation(options, t) {
|
|
|
15147
14974
|
options.thinking = { type: "enabled" };
|
|
15148
14975
|
}
|
|
15149
14976
|
}
|
|
15150
|
-
|
|
15151
|
-
|
|
15152
|
-
|
|
15153
|
-
|
|
15154
|
-
|
|
15155
|
-
|
|
15156
|
-
|
|
15157
|
-
quick: "You are forge-quick, a mechanical small-change worker. Make the smallest correct edit the task names (rename, constant, one-liner fix). No refactoring, no drive-by improvements."
|
|
15158
|
-
};
|
|
15159
|
-
function tierAgentPrompt(tierId) {
|
|
15160
|
-
return TIER_PROMPTS[tierId] ?? `You are forge-${tierId}, a dispatched worker. Execute the dispatched task exactly as scoped.`;
|
|
15161
|
-
}
|
|
15162
|
-
var ROLE_PROMPTS = {
|
|
15163
|
-
research: "You are a research worker: search, read, and run read-only checks to answer the dispatched question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
|
|
15164
|
-
review: "You are a review worker: read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list, each with file:line evidence and a concrete fix suggestion. End with a single-line verdict."
|
|
15165
|
-
};
|
|
15166
|
-
function rolePromptFor(agentId, def) {
|
|
15167
|
-
if (def?.prompt && def.prompt.trim().length > 0)
|
|
15168
|
-
return def.prompt;
|
|
15169
|
-
return ROLE_PROMPTS[agentId] ?? `You are forge-${agentId}, a dispatched worker. Execute the dispatched task exactly as scoped.`;
|
|
15170
|
-
}
|
|
15171
|
-
function forgeAgentDef(agentId, def) {
|
|
15172
|
-
const permission = { task: "deny" };
|
|
15173
|
-
const shape = def.shape ?? "readonly";
|
|
15174
|
-
if (shape === "readonly") {
|
|
15175
|
-
for (const t of MUTATING_TOOLS)
|
|
15176
|
-
permission[t] = "deny";
|
|
15177
|
-
}
|
|
15178
|
-
Object.assign(permission, def.permission ?? {});
|
|
15179
|
-
permission.task = "deny";
|
|
15180
|
-
const shapeLine = shape === "readonly" ? "Readonly agent: mutating tools are denied by design — report findings, never work around restrictions." : "Write agent: execute the scoped task with minimal, focused changes.";
|
|
15181
|
-
return {
|
|
15182
|
-
description: `forge dispatch agent "${agentId}" (${shape}) — spawned by forge_dispatch, not user-facing.`,
|
|
15183
|
-
mode: "subagent",
|
|
15184
|
-
hidden: true,
|
|
15185
|
-
prompt: `${rolePromptFor(agentId, def)}
|
|
15186
|
-
|
|
15187
|
-
${shapeLine}`,
|
|
15188
|
-
permission
|
|
15189
|
-
};
|
|
15190
|
-
}
|
|
15191
|
-
function tierAgentDef(tierId, tier) {
|
|
15192
|
-
const permission = { task: "deny" };
|
|
15193
|
-
if (tier.shape === "readonly") {
|
|
15194
|
-
for (const t of MUTATING_TOOLS)
|
|
15195
|
-
permission[t] = "deny";
|
|
15196
|
-
}
|
|
15197
|
-
const shapeLine = tier.shape === "readonly" ? "Readonly tier: mutating tools are denied by design — report findings, never work around restrictions." : "Write tier: execute the scoped task with minimal, focused changes.";
|
|
15198
|
-
const defaultLine = tier.defaultDepth ? ` Default reasoning depth: ${tier.defaultDepth}.` : "";
|
|
15199
|
-
return {
|
|
15200
|
-
description: `forge dispatch tier "${tierId}" (${tier.shape}) — spawned by forge_dispatch, not user-facing.${defaultLine}`,
|
|
15201
|
-
mode: "subagent",
|
|
15202
|
-
hidden: true,
|
|
15203
|
-
prompt: `${tierAgentPrompt(tierId)}
|
|
15204
|
-
|
|
15205
|
-
${shapeLine}`,
|
|
15206
|
-
permission
|
|
15207
|
-
};
|
|
15208
|
-
}
|
|
15209
|
-
|
|
15210
|
-
// src/dispatch-engine.ts
|
|
15211
|
-
var DEFAULT_DISPATCH_TIMEOUT_MS = 600000;
|
|
15212
|
-
var MAX_DISPATCH_TIMEOUT_MS = 600000;
|
|
15213
|
-
var DEFAULT_MAX_CONCURRENT = 4;
|
|
15214
|
-
var DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
15215
|
-
|
|
15216
|
-
class DispatchError extends Error {
|
|
15217
|
-
code;
|
|
15218
|
-
constructor(message, code) {
|
|
15219
|
-
super(message);
|
|
15220
|
-
this.name = "DispatchError";
|
|
15221
|
-
this.code = code;
|
|
15222
|
-
}
|
|
15223
|
-
}
|
|
15224
|
-
function textOf(messages) {
|
|
15225
|
-
const assistant = [...messages].reverse().find((m) => (m.info?.role ?? m.role) === "assistant");
|
|
15226
|
-
if (!assistant)
|
|
15227
|
-
return "";
|
|
15228
|
-
return (assistant.parts ?? []).filter((p) => p.type === "text" || typeof p.text === "string").map((p) => p.text ?? "").join(`
|
|
15229
|
-
`).trim();
|
|
15230
|
-
}
|
|
15231
|
-
function tokensOf(messages) {
|
|
15232
|
-
return messages.filter((m) => (m.info?.role ?? m.role) === "assistant" && m.info?.tokens).map((m) => m.info.tokens);
|
|
15233
|
-
}
|
|
15234
|
-
function createDispatchEngine(deps) {
|
|
15235
|
-
const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
15236
|
-
const timeoutMs = Math.min(deps.timeoutMs ?? DEFAULT_DISPATCH_TIMEOUT_MS, MAX_DISPATCH_TIMEOUT_MS);
|
|
15237
|
-
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
15238
|
-
const inFlight = new Set;
|
|
15239
|
-
let seq = 0;
|
|
15240
|
-
const api2 = async (path, init) => {
|
|
15241
|
-
const res = await deps.fetcher(`${deps.serverUrl}${path}`, {
|
|
15242
|
-
...init,
|
|
15243
|
-
headers: { "content-type": "application/json", ...init?.headers ?? {} }
|
|
15244
|
-
});
|
|
15245
|
-
const text = await res.text();
|
|
15246
|
-
if (!res.ok)
|
|
15247
|
-
throw new DispatchError(`${init?.method ?? "GET"} ${path} -> ${res.status}: ${text.slice(0, 300)}`, "host-error");
|
|
15248
|
-
return text ? JSON.parse(text) : undefined;
|
|
15249
|
-
};
|
|
15250
|
-
const fetchMessages = async (sessionID) => {
|
|
15251
|
-
const list = await api2(`/session/${sessionID}/message`);
|
|
15252
|
-
return Array.isArray(list) ? list : [];
|
|
15253
|
-
};
|
|
15254
|
-
const killControls = new Map;
|
|
15255
|
-
const resolveOrThrow = (profile, depth, exclude) => {
|
|
15256
|
-
const r = resolveDispatch(deps.cfg, { profile, depth, exclude }, deps.available, deps.catalog);
|
|
15257
|
-
if (!r.ok) {
|
|
15258
|
-
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", tier: profile, outcome: r.error.code, note: r.error.message });
|
|
15259
|
-
throw new DispatchError(`${r.error.message}
|
|
15260
|
-
Menu:
|
|
15261
|
-
${r.error.menu.map((m) => `- ${m.identity} expose=[${m.expose.join(", ") || "none"}] ${m.reason}`).join(`
|
|
15262
|
-
`)}
|
|
15263
|
-
Vocabulary: ${r.error.vocabulary.join(", ") || "(none)"}`, r.error.code);
|
|
15264
|
-
}
|
|
15265
|
-
return r;
|
|
15266
|
-
};
|
|
15267
|
-
const resolveForThrow = (req, exclude) => {
|
|
15268
|
-
if (deps.legacy && req.agent in deps.cfg.tiers) {
|
|
15269
|
-
const r = resolveOrThrow(req.agent, req.depth, exclude);
|
|
15270
|
-
return {
|
|
15271
|
-
model: r.identity,
|
|
15272
|
-
depth: r.depth,
|
|
15273
|
-
agentPath: false,
|
|
15274
|
-
shape: deps.cfg.tiers[req.agent]?.shape ?? "readonly",
|
|
15275
|
-
role: tierAgentPrompt(req.agent)
|
|
15276
|
-
};
|
|
15277
|
-
}
|
|
15278
|
-
const a = resolveAgent(deps.agents(), { agent: req.agent, depth: req.depth }, deps.available);
|
|
15279
|
-
if (!a.ok) {
|
|
15280
|
-
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", tier: req.agent, agent: req.agent, outcome: a.error.code, note: a.error.message });
|
|
15281
|
-
throw new DispatchError(a.error.message, a.error.code);
|
|
15282
|
-
}
|
|
15283
|
-
return {
|
|
15284
|
-
model: a.model,
|
|
15285
|
-
depth: a.depth,
|
|
15286
|
-
agentPath: true,
|
|
15287
|
-
shape: a.def?.shape === "write" ? "write" : "readonly",
|
|
15288
|
-
role: rolePromptFor(req.agent, a.def)
|
|
15289
|
-
};
|
|
15290
|
-
};
|
|
15291
|
-
const translationFor = (identity, depth) => {
|
|
15292
|
-
const providerID = identity.slice(0, identity.indexOf("/"));
|
|
15293
|
-
const family = deps.providerFamily(providerID) ?? "unknown";
|
|
15294
|
-
const ladder = nativeLadder(deps.catalog, identity);
|
|
15295
|
-
return translateDepth(depth, family, ladder);
|
|
15296
|
-
};
|
|
15297
|
-
const translationOrThrow = (identity, depth) => {
|
|
15298
|
-
const translation = translationFor(identity, depth);
|
|
15299
|
-
if (translation.kind === "no-mapping") {
|
|
15300
|
-
deps.sink({ ts: new Date().toISOString(), event: "resolve-error", agent: undefined, identity, depth, outcome: "no-native-mapping", note: translation.message });
|
|
15301
|
-
throw new DispatchError(translation.message, "no-native-mapping");
|
|
15302
|
-
}
|
|
15303
|
-
return translation;
|
|
15304
|
-
};
|
|
15305
|
-
async function runAttempt(r, req, parentSessionID, t0, ctl, onChild, dispatchId) {
|
|
15306
|
-
const providerID = r.model.slice(0, r.model.indexOf("/"));
|
|
15307
|
-
const translation = translationOrThrow(r.model, r.depth);
|
|
15308
|
-
let childID = "";
|
|
15309
|
-
const dir = encodeURIComponent(deps.workspace);
|
|
15310
|
-
let created;
|
|
15311
|
-
try {
|
|
15312
|
-
created = await api2(`/session?directory=${dir}`, { method: "POST", body: JSON.stringify(parentSessionID ? { parentID: parentSessionID } : {}) });
|
|
15313
|
-
} catch {
|
|
15314
|
-
created = await api2(`/session?directory=${dir}`, { method: "POST", body: JSON.stringify({}) });
|
|
15315
|
-
}
|
|
15316
|
-
childID = created?.id ?? "";
|
|
15317
|
-
if (!childID)
|
|
15318
|
-
throw new DispatchError("host returned no session id for the child session", "host-error");
|
|
15319
|
-
onChild?.(childID);
|
|
15320
|
-
deps.onDepth(childID, translation);
|
|
15321
|
-
const depthInjected = translation.disclose;
|
|
15322
|
-
let deadlineHit = false;
|
|
15323
|
-
const deadlinePromise = deps.sleep(Math.max(timeoutMs - (deps.now() - t0), 0)).then(() => {
|
|
15324
|
-
deadlineHit = true;
|
|
15325
|
-
});
|
|
15326
|
-
const turnPost = api2(`/session/${childID}/message`, {
|
|
15327
|
-
method: "POST",
|
|
15328
|
-
body: JSON.stringify({
|
|
15329
|
-
model: { providerID, modelID: r.model.slice(r.model.indexOf("/") + 1) },
|
|
15330
|
-
agent: `forge-${req.agent}`,
|
|
15331
|
-
parts: [{ type: "text", text: composeWorkerPrompt({ prompt: req.prompt, agent: req.agent, shape: r.shape, ...r.role ? { role: r.role } : {} }) }]
|
|
15332
|
-
})
|
|
15333
|
-
});
|
|
15334
|
-
turnPost.catch(() => {});
|
|
15335
|
-
try {
|
|
15336
|
-
await Promise.race([turnPost, deadlinePromise]);
|
|
15337
|
-
if (deadlineHit && deps.now() - t0 >= timeoutMs) {
|
|
15338
|
-
const elapsed = deps.now() - t0;
|
|
15339
|
-
deps.sink({
|
|
15340
|
-
ts: new Date().toISOString(),
|
|
15341
|
-
event: "timeout",
|
|
15342
|
-
tier: req.agent,
|
|
15343
|
-
agent: req.agent,
|
|
15344
|
-
identity: r.model,
|
|
15345
|
-
depth: r.depth,
|
|
15346
|
-
sessionID: childID,
|
|
15347
|
-
durationMs: elapsed,
|
|
15348
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15349
|
-
});
|
|
15350
|
-
throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
|
|
15351
|
-
}
|
|
15352
|
-
await turnPost;
|
|
15353
|
-
} catch (e) {
|
|
15354
|
-
if (e instanceof DispatchError)
|
|
15355
|
-
throw e;
|
|
15356
|
-
deps.sink({
|
|
15357
|
-
ts: new Date().toISOString(),
|
|
15358
|
-
event: "transport-interrupted",
|
|
15359
|
-
tier: req.agent,
|
|
15360
|
-
agent: req.agent,
|
|
15361
|
-
identity: r.model,
|
|
15362
|
-
depth: r.depth,
|
|
15363
|
-
sessionID: childID,
|
|
15364
|
-
durationMs: deps.now() - t0,
|
|
15365
|
-
note: e instanceof Error ? e.message : String(e),
|
|
15366
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15367
|
-
});
|
|
15368
|
-
}
|
|
15369
|
-
let poll = null;
|
|
15370
|
-
let messages = [];
|
|
15371
|
-
for (;; ) {
|
|
15372
|
-
if (ctl?.fired)
|
|
15373
|
-
throw new KillRequested;
|
|
15374
|
-
const elapsed = deps.now() - t0;
|
|
15375
|
-
if (elapsed >= timeoutMs) {
|
|
15376
|
-
deps.sink({
|
|
15377
|
-
ts: new Date().toISOString(),
|
|
15378
|
-
event: "timeout",
|
|
15379
|
-
tier: req.agent,
|
|
15380
|
-
agent: req.agent,
|
|
15381
|
-
identity: r.model,
|
|
15382
|
-
depth: r.depth,
|
|
15383
|
-
sessionID: childID,
|
|
15384
|
-
durationMs: elapsed,
|
|
15385
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15386
|
-
});
|
|
15387
|
-
throw new DispatchError(`forge_dispatch timed out after ${elapsed}ms (deadline ${timeoutMs}ms). Partial state: child session ${childID} is left for the host to reclaim; its transcript remains queryable via the host API.`, "timeout");
|
|
15388
|
-
}
|
|
15389
|
-
await deps.sleep(pollIntervalMs);
|
|
15390
|
-
messages = await (ctl ? Promise.race([fetchMessages(childID), ctl.promise.then(() => [])]) : fetchMessages(childID));
|
|
15391
|
-
if (ctl?.fired)
|
|
15392
|
-
throw new KillRequested;
|
|
15393
|
-
poll = completionVerdict(poll, { count: messages.length, text: textOf(messages) });
|
|
15394
|
-
if (poll.verdict === "complete" || poll.verdict === "empty")
|
|
15395
|
-
break;
|
|
15396
|
-
}
|
|
15397
|
-
const durationMs = deps.now() - t0;
|
|
15398
|
-
if (poll.verdict === "empty") {
|
|
15399
|
-
deps.sink({
|
|
15400
|
-
ts: new Date().toISOString(),
|
|
15401
|
-
event: "empty-response",
|
|
15402
|
-
tier: req.agent,
|
|
15403
|
-
agent: req.agent,
|
|
15404
|
-
identity: r.model,
|
|
15405
|
-
depth: r.depth,
|
|
15406
|
-
sessionID: childID,
|
|
15407
|
-
durationMs,
|
|
15408
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15409
|
-
});
|
|
15410
|
-
throw new DispatchError(`child session ${childID} returned an empty response (0-token assistant message). This is a known quirk of keyless endpoints combined with restricted tiers — see the README dispatch notes. The dispatch is NOT counted as success.`, "empty-response");
|
|
15411
|
-
}
|
|
15412
|
-
const tokens = sumTokens(tokensOf(messages));
|
|
15413
|
-
const price = priceFor(deps.catalog, r.model);
|
|
15414
|
-
const cost = computeCost(tokens, price?.spec ?? null);
|
|
15415
|
-
const result = {
|
|
15416
|
-
agent: req.agent,
|
|
15417
|
-
requested: { agent: req.agent, depth: req.depth },
|
|
15418
|
-
actual: { model: r.model, depth: r.depth },
|
|
15419
|
-
sessionID: childID,
|
|
15420
|
-
durationMs,
|
|
15421
|
-
tokens,
|
|
15422
|
-
costUsd: cost.costUsd,
|
|
15423
|
-
...cost.note ? { costNote: cost.note } : {},
|
|
15424
|
-
...price?.label ? { priceSnapshot: price.label } : {},
|
|
15425
|
-
text: textOf(messages),
|
|
15426
|
-
depthTranslation: depthInjected
|
|
15427
|
-
};
|
|
15428
|
-
deps.sink({
|
|
15429
|
-
ts: new Date().toISOString(),
|
|
15430
|
-
event: "completed",
|
|
15431
|
-
tier: req.agent,
|
|
15432
|
-
agent: req.agent,
|
|
15433
|
-
identity: r.model,
|
|
15434
|
-
depth: r.depth,
|
|
15435
|
-
sessionID: childID,
|
|
15436
|
-
outcome: "completed",
|
|
15437
|
-
tokens,
|
|
15438
|
-
costUsd: cost.costUsd,
|
|
15439
|
-
durationMs,
|
|
15440
|
-
...dispatchId !== undefined ? { dispatchId, parentSessionID: parentSessionID ?? "" } : {}
|
|
15441
|
-
});
|
|
15442
|
-
return result;
|
|
15443
|
-
}
|
|
15444
|
-
function bgTerminal(reg, dispatchId, identity, req, t0, e) {
|
|
15445
|
-
let entrySessionID = "";
|
|
15446
|
-
let parentSessionID = "";
|
|
15447
|
-
try {
|
|
15448
|
-
const entry = reg.get(dispatchId);
|
|
15449
|
-
entrySessionID = entry.sessionID;
|
|
15450
|
-
parentSessionID = entry.parentSessionID;
|
|
15451
|
-
} catch {}
|
|
15452
|
-
const durationMs = deps.now() - t0;
|
|
15453
|
-
const report = {
|
|
15454
|
-
agent: req.agent,
|
|
15455
|
-
requested: { agent: req.agent, depth: req.depth },
|
|
15456
|
-
actual: { model: identity, depth: null },
|
|
15457
|
-
sessionID: entrySessionID,
|
|
15458
|
-
outcome: "error",
|
|
15459
|
-
durationMs,
|
|
15460
|
-
error: e instanceof Error ? e.message : String(e)
|
|
15461
|
-
};
|
|
15462
|
-
if (e instanceof DispatchError && e.code === "timeout") {
|
|
15463
|
-
report.outcome = "timeout";
|
|
15464
|
-
reg.markTimeout(dispatchId, report);
|
|
15465
|
-
deps.onTerminal?.(parentSessionID);
|
|
15466
|
-
return;
|
|
15467
|
-
}
|
|
15468
|
-
deps.sink({
|
|
15469
|
-
ts: new Date().toISOString(),
|
|
15470
|
-
event: "error",
|
|
15471
|
-
tier: req.agent,
|
|
15472
|
-
agent: req.agent,
|
|
15473
|
-
identity,
|
|
15474
|
-
outcome: "error",
|
|
15475
|
-
sessionID: entrySessionID,
|
|
15476
|
-
durationMs,
|
|
15477
|
-
note: report.error,
|
|
15478
|
-
dispatchId,
|
|
15479
|
-
parentSessionID
|
|
15480
|
-
});
|
|
15481
|
-
reg.markError(dispatchId, report);
|
|
15482
|
-
deps.onTerminal?.(parentSessionID);
|
|
15483
|
-
}
|
|
15484
|
-
async function dispatch(req, parentSessionID, opts) {
|
|
15485
|
-
const t0 = deps.now();
|
|
15486
|
-
if (inFlight.size >= maxConcurrent) {
|
|
15487
|
-
deps.sink({ ts: new Date().toISOString(), event: "refused-cap", tier: req.agent, agent: req.agent, note: `${inFlight.size} in flight` });
|
|
15488
|
-
throw new DispatchError(`forge_dispatch refused: ${inFlight.size} dispatches already in flight (cap ${maxConcurrent}). Wait for a completion and retry.`, "cap-refused");
|
|
15489
|
-
}
|
|
15490
|
-
const bg = opts?.background === true;
|
|
15491
|
-
if (bg && !deps.registry)
|
|
15492
|
-
throw new DispatchError("background dispatch requires the dispatch registry (internal error)", "host-error");
|
|
15493
|
-
const first = resolveForThrow(req, undefined);
|
|
15494
|
-
translationOrThrow(first.model, first.depth);
|
|
15495
|
-
if (bg) {
|
|
15496
|
-
const reg = deps.registry;
|
|
15497
|
-
const { dispatchId } = reg.submit({ sessionID: "", identity: first.model, depth: first.depth, tier: req.agent, parentSessionID: parentSessionID ?? "" });
|
|
15498
|
-
let fire = () => {};
|
|
15499
|
-
const promise2 = new Promise((res) => {
|
|
15500
|
-
fire = res;
|
|
15501
|
-
});
|
|
15502
|
-
const ctl = { fired: false, promise: promise2, fire: () => {
|
|
15503
|
-
ctl.fired = true;
|
|
15504
|
-
fire();
|
|
15505
|
-
} };
|
|
15506
|
-
killControls.set(dispatchId, ctl);
|
|
15507
|
-
const slot = `d${++seq}`;
|
|
15508
|
-
inFlight.add(slot);
|
|
15509
|
-
(async () => {
|
|
15510
|
-
try {
|
|
15511
|
-
if (reg.get(dispatchId).state === "killed")
|
|
15512
|
-
return;
|
|
15513
|
-
reg.markRunning(dispatchId);
|
|
15514
|
-
let current = first;
|
|
15515
|
-
let exclude2 = undefined;
|
|
15516
|
-
for (let attemptNo = 0;; attemptNo++) {
|
|
15517
|
-
try {
|
|
15518
|
-
const result = await runAttempt(current, req, parentSessionID, t0, ctl, (sid) => {
|
|
15519
|
-
try {
|
|
15520
|
-
reg.get(dispatchId).sessionID = sid;
|
|
15521
|
-
} catch {}
|
|
15522
|
-
}, dispatchId);
|
|
15523
|
-
if (reg.get(dispatchId).state === "killed") {
|
|
15524
|
-
reg.noteLateCompletion(dispatchId, result);
|
|
15525
|
-
deps.sink({ ts: new Date().toISOString(), event: "kill-late-completion", tier: req.agent, agent: req.agent, identity: current.model, sessionID: result.sessionID, outcome: "kill-late-completion" });
|
|
15526
|
-
} else {
|
|
15527
|
-
reg.markCompleted(dispatchId, result);
|
|
15528
|
-
deps.onTerminal?.(reg.get(dispatchId).parentSessionID);
|
|
15529
|
-
}
|
|
15530
|
-
return;
|
|
15531
|
-
} catch (e) {
|
|
15532
|
-
if (e instanceof KillRequested)
|
|
15533
|
-
return;
|
|
15534
|
-
if (reg.get(dispatchId).state === "killed")
|
|
15535
|
-
return;
|
|
15536
|
-
const retryable = !current.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
|
|
15537
|
-
const alternative = retryable ? resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [current.model] }, deps.available, deps.catalog) : { ok: false };
|
|
15538
|
-
if (!retryable || attemptNo > 0 || !alternative.ok) {
|
|
15539
|
-
bgTerminal(reg, dispatchId, current.model, req, t0, e);
|
|
15540
|
-
return;
|
|
15541
|
-
}
|
|
15542
|
-
deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity: current.model, note: `${current.model}: ${e.code}` });
|
|
15543
|
-
exclude2 = [current.model];
|
|
15544
|
-
current = resolveForThrow(req, exclude2);
|
|
15545
|
-
reg.get(dispatchId).identity = current.model;
|
|
15546
|
-
}
|
|
15547
|
-
}
|
|
15548
|
-
} finally {
|
|
15549
|
-
inFlight.delete(slot);
|
|
15550
|
-
killControls.delete(dispatchId);
|
|
15551
|
-
}
|
|
15552
|
-
})();
|
|
15553
|
-
return {
|
|
15554
|
-
dispatchId,
|
|
15555
|
-
agent: req.agent,
|
|
15556
|
-
requested: { agent: req.agent, depth: req.depth },
|
|
15557
|
-
resolved: first.model,
|
|
15558
|
-
depth: first.depth,
|
|
15559
|
-
queuedAt: reg.get(dispatchId).queuedAt
|
|
15560
|
-
};
|
|
15561
|
-
}
|
|
15562
|
-
let exclude = undefined;
|
|
15563
|
-
for (let attemptNo = 0;; attemptNo++) {
|
|
15564
|
-
const r = attemptNo === 0 ? first : resolveForThrow(req, exclude);
|
|
15565
|
-
const slot = `d${++seq}`;
|
|
15566
|
-
inFlight.add(slot);
|
|
15567
|
-
try {
|
|
15568
|
-
return await runAttempt(r, req, parentSessionID, t0, undefined, undefined);
|
|
15569
|
-
} catch (e) {
|
|
15570
|
-
const identity = r.model;
|
|
15571
|
-
const retryable = !r.agentPath && e instanceof DispatchError && (e.code === "host-error" || e.code === "empty-response");
|
|
15572
|
-
if (!retryable || attemptNo > 0)
|
|
15573
|
-
throw e;
|
|
15574
|
-
const alternative = resolveDispatch(deps.cfg, { profile: req.agent, depth: req.depth, exclude: [identity] }, deps.available, deps.catalog);
|
|
15575
|
-
if (!alternative.ok)
|
|
15576
|
-
throw e;
|
|
15577
|
-
deps.sink({ ts: new Date().toISOString(), event: "retry-excluded", tier: req.agent, agent: req.agent, identity, note: `${identity}: ${e.code}` });
|
|
15578
|
-
exclude = [identity];
|
|
15579
|
-
} finally {
|
|
15580
|
-
inFlight.delete(slot);
|
|
15581
|
-
}
|
|
15582
|
-
}
|
|
15583
|
-
}
|
|
15584
|
-
return {
|
|
15585
|
-
dispatch,
|
|
15586
|
-
kill: (dispatchId) => {
|
|
15587
|
-
const reg = deps.registry;
|
|
15588
|
-
if (!reg)
|
|
15589
|
-
throw new DispatchError("forge_dispatch_kill unavailable: no dispatch registry (internal error)", "kill-failed");
|
|
15590
|
-
const e = reg.get(dispatchId);
|
|
15591
|
-
if (e.state === "completed" || e.state === "timeout" || e.state === "error") {
|
|
15592
|
-
throw new DispatchError(`forge_dispatch_kill refused: dispatch ${dispatchId} already ${e.state}; use forge_dispatch_list for its result.`, "kill-failed");
|
|
15593
|
-
}
|
|
15594
|
-
if (e.state === "killed")
|
|
15595
|
-
throw new DispatchError(`dispatch ${dispatchId} is already killed.`, "kill-failed");
|
|
15596
|
-
reg.markKilled(dispatchId);
|
|
15597
|
-
deps.sink({ ts: new Date().toISOString(), event: "killed", tier: e.tier, identity: e.identity, depth: e.depth ?? undefined, sessionID: e.sessionID, outcome: "killed" });
|
|
15598
|
-
killControls.get(dispatchId)?.fire();
|
|
15599
|
-
},
|
|
15600
|
-
inFlightCount: () => inFlight.size,
|
|
15601
|
-
inFlightIDs: () => [...inFlight],
|
|
15602
|
-
dispose: () => {
|
|
15603
|
-
for (const slot of inFlight) {
|
|
15604
|
-
deps.sink({ ts: new Date().toISOString(), event: "lost-on-exit", note: `dispatch ${slot} in flight at host exit` });
|
|
15605
|
-
}
|
|
15606
|
-
inFlight.clear();
|
|
15607
|
-
}
|
|
15608
|
-
};
|
|
15609
|
-
}
|
|
15610
|
-
|
|
15611
|
-
class KillRequested extends Error {
|
|
15612
|
-
constructor() {
|
|
15613
|
-
super("kill requested");
|
|
15614
|
-
}
|
|
15615
|
-
}
|
|
15616
|
-
function priceFor(catalog, identity) {
|
|
15617
|
-
const slash = identity.indexOf("/");
|
|
15618
|
-
if (slash <= 0)
|
|
15619
|
-
return null;
|
|
15620
|
-
const model = catalog.providers?.[identity.slice(0, slash)]?.models?.[identity.slice(slash + 1)];
|
|
15621
|
-
const cost = model?.cost;
|
|
15622
|
-
if (!cost)
|
|
15623
|
-
return null;
|
|
15624
|
-
return {
|
|
15625
|
-
spec: {
|
|
15626
|
-
input: cost.input,
|
|
15627
|
-
output: cost.output,
|
|
15628
|
-
cacheRead: cost.cache_read,
|
|
15629
|
-
cacheWrite: cost.cache_write
|
|
15630
|
-
},
|
|
15631
|
-
label: "models.dev snapshot"
|
|
15632
|
-
};
|
|
15633
|
-
}
|
|
15634
|
-
|
|
15635
|
-
// src/dispatch-ledger.ts
|
|
15636
|
-
import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
15637
|
-
import { dirname as dirname3 } from "node:path";
|
|
15638
|
-
var DISPATCH_LEDGER_MAX_ENTRIES = 200;
|
|
15639
|
-
var DISPATCH_LEDGER_MAX_BYTES = 1e6;
|
|
15640
|
-
function createDispatchLedger(logPath, maxEntries = DISPATCH_LEDGER_MAX_ENTRIES, maxBytes = DISPATCH_LEDGER_MAX_BYTES) {
|
|
15641
|
-
const append = (entry) => {
|
|
15642
|
-
try {
|
|
15643
|
-
mkdirSync4(dirname3(logPath), { recursive: true });
|
|
15644
|
-
let lines = [];
|
|
15645
|
-
try {
|
|
15646
|
-
lines = readFileSync6(logPath, "utf8").split(`
|
|
15647
|
-
`).filter((l) => l.trim().length > 0);
|
|
15648
|
-
} catch {}
|
|
15649
|
-
if (lines.length >= maxEntries)
|
|
15650
|
-
lines = lines.slice(lines.length - maxEntries + 1);
|
|
15651
|
-
lines.push(JSON.stringify(entry));
|
|
15652
|
-
let text = lines.join(`
|
|
15653
|
-
`) + `
|
|
15654
|
-
`;
|
|
15655
|
-
if (text.length > maxBytes) {
|
|
15656
|
-
const keep = Math.max(1, Math.floor(maxEntries / 2));
|
|
15657
|
-
text = lines.slice(-keep).join(`
|
|
15658
|
-
`) + `
|
|
15659
|
-
`;
|
|
15660
|
-
}
|
|
15661
|
-
writeFileSync4(logPath, text, "utf8");
|
|
15662
|
-
} catch {}
|
|
15663
|
-
};
|
|
15664
|
-
const entries = () => {
|
|
15665
|
-
try {
|
|
15666
|
-
return readFileSync6(logPath, "utf8").split(`
|
|
15667
|
-
`).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
15668
|
-
} catch {
|
|
15669
|
-
return [];
|
|
15670
|
-
}
|
|
15671
|
-
};
|
|
15672
|
-
return { append, entries, path: logPath };
|
|
15673
|
-
}
|
|
15674
|
-
|
|
15675
|
-
// src/dispatch-registry.ts
|
|
15676
|
-
var TERMINAL_CAP = 50;
|
|
15677
|
-
function createDispatchRegistry() {
|
|
15678
|
-
const entries = new Map;
|
|
15679
|
-
let seq = 0;
|
|
15680
|
-
const fail = (msg) => {
|
|
15681
|
-
throw new Error(`[forge:dispatch-registry] ${msg}`);
|
|
15682
|
-
};
|
|
15683
|
-
const isTerminal2 = (st) => st === "completed" || st === "timeout" || st === "killed" || st === "error";
|
|
15684
|
-
return {
|
|
15685
|
-
submit(entry) {
|
|
15686
|
-
const dispatchId = `bg-${++seq}`;
|
|
15687
|
-
entries.set(dispatchId, {
|
|
15688
|
-
...entry,
|
|
15689
|
-
dispatchId,
|
|
15690
|
-
background: true,
|
|
15691
|
-
queuedAt: entry.queuedAt ?? new Date().toISOString(),
|
|
15692
|
-
state: "queued",
|
|
15693
|
-
delivered: false
|
|
15694
|
-
});
|
|
15695
|
-
return { dispatchId };
|
|
15696
|
-
},
|
|
15697
|
-
get(dispatchId) {
|
|
15698
|
-
const e = entries.get(dispatchId);
|
|
15699
|
-
if (!e)
|
|
15700
|
-
throw new Error(`[forge:dispatch-registry] unknown dispatch id "${dispatchId}"`);
|
|
15701
|
-
return e;
|
|
15702
|
-
},
|
|
15703
|
-
markRunning(dispatchId) {
|
|
15704
|
-
const e = this.get(dispatchId);
|
|
15705
|
-
if (e.state !== "queued")
|
|
15706
|
-
fail(`illegal transition ${e.state} → running for ${dispatchId}`);
|
|
15707
|
-
e.state = "running";
|
|
15708
|
-
return e;
|
|
15709
|
-
},
|
|
15710
|
-
markCompleted(dispatchId, result) {
|
|
15711
|
-
return this.terminate(dispatchId, "completed", result);
|
|
15712
|
-
},
|
|
15713
|
-
markTimeout(dispatchId, report) {
|
|
15714
|
-
return this.terminate(dispatchId, "timeout", report);
|
|
15715
|
-
},
|
|
15716
|
-
markError(dispatchId, report) {
|
|
15717
|
-
return this.terminate(dispatchId, "error", report);
|
|
15718
|
-
},
|
|
15719
|
-
markKilled(dispatchId) {
|
|
15720
|
-
return this.terminate(dispatchId, "killed");
|
|
15721
|
-
},
|
|
15722
|
-
noteLateCompletion(dispatchId, result) {
|
|
15723
|
-
const e = this.get(dispatchId);
|
|
15724
|
-
if (e.state !== "killed")
|
|
15725
|
-
fail(`late completion only makes sense on a killed dispatch (${dispatchId} is ${e.state})`);
|
|
15726
|
-
e.lateCompletion = result;
|
|
15727
|
-
},
|
|
15728
|
-
terminate(dispatchId, state, result) {
|
|
15729
|
-
const e = this.get(dispatchId);
|
|
15730
|
-
if (isTerminal2(e.state))
|
|
15731
|
-
fail(`illegal transition ${e.state} → ${state} for ${dispatchId}`);
|
|
15732
|
-
e.state = state;
|
|
15733
|
-
if (result !== undefined)
|
|
15734
|
-
e.result = result;
|
|
15735
|
-
return e;
|
|
15736
|
-
},
|
|
15737
|
-
takeUndelivered(parentSessionID) {
|
|
15738
|
-
const out = [];
|
|
15739
|
-
for (const e of entries.values()) {
|
|
15740
|
-
if (parentSessionID !== undefined && e.parentSessionID !== parentSessionID)
|
|
15741
|
-
continue;
|
|
15742
|
-
if (isTerminal2(e.state) && !e.delivered && e.state !== "killed") {
|
|
15743
|
-
e.delivered = true;
|
|
15744
|
-
out.push(e);
|
|
15745
|
-
}
|
|
15746
|
-
}
|
|
15747
|
-
return out;
|
|
15748
|
-
},
|
|
15749
|
-
list() {
|
|
15750
|
-
const inFlight = [];
|
|
15751
|
-
const terminal = [];
|
|
15752
|
-
for (const e of entries.values()) {
|
|
15753
|
-
if (isTerminal2(e.state))
|
|
15754
|
-
terminal.push(e);
|
|
15755
|
-
else
|
|
15756
|
-
inFlight.push(e);
|
|
15757
|
-
}
|
|
15758
|
-
return { inFlight, terminal: terminal.slice(-TERMINAL_CAP) };
|
|
15759
|
-
}
|
|
15760
|
-
};
|
|
15761
|
-
}
|
|
15762
|
-
|
|
15763
|
-
// src/crew-gate.ts
|
|
15764
|
-
var CREW_DISPATCH_EVENTS = new Set(["completed", "timeout", "error", "killed", "empty-response"]);
|
|
15765
|
-
var DISPATCH_ID = /bg-\d+/g;
|
|
15766
|
-
function validateCrewReport(report, ledgerRows, crew) {
|
|
15767
|
-
const gaps = [];
|
|
15768
|
-
if (!Array.isArray(report) || report.length === 0) {
|
|
15769
|
-
return { ok: false, gaps: ["the report lists no subtasks — a crew completion report must cover every subtask"], dispatchIds: [] };
|
|
15770
|
-
}
|
|
15771
|
-
const rowById = new Map;
|
|
15772
|
-
for (const row of ledgerRows) {
|
|
15773
|
-
if (row.parentSessionID !== crew.sessionID)
|
|
15774
|
-
continue;
|
|
15775
|
-
if (row.ts < crew.startedAt)
|
|
15776
|
-
continue;
|
|
15777
|
-
if (row.event === "validation" || row.event === "resolve-error" || row.event === "refused-cap" || row.event === "models-dev-degraded" || row.event === "retry-excluded" || row.event === "lost-on-exit" || row.event === "kill-late-completion" || row.event === "transport-interrupted")
|
|
15778
|
-
continue;
|
|
15779
|
-
if (CREW_DISPATCH_EVENTS.has(row.event) && row.dispatchId)
|
|
15780
|
-
rowById.set(row.dispatchId, row);
|
|
15781
|
-
}
|
|
15782
|
-
const referenced = new Set;
|
|
15783
|
-
report.forEach((sub, i) => {
|
|
15784
|
-
const label = sub?.title?.trim() || `subtask #${i + 1}`;
|
|
15785
|
-
const verdict = String(sub?.verdict ?? "").toUpperCase();
|
|
15786
|
-
if (!sub?.title?.trim())
|
|
15787
|
-
gaps.push(`${label}: missing title`);
|
|
15788
|
-
if (verdict !== "PASS" && verdict !== "FAIL")
|
|
15789
|
-
gaps.push(`${label}: verdict must be PASS or FAIL (got "${sub?.verdict ?? ""}")`);
|
|
15790
|
-
const ids = [...String(sub?.evidence ?? "").matchAll(DISPATCH_ID)].map((m) => m[0]);
|
|
15791
|
-
if (!String(sub?.evidence ?? "").trim())
|
|
15792
|
-
gaps.push(`${label}: missing evidence reference`);
|
|
15793
|
-
else if (ids.length === 0)
|
|
15794
|
-
gaps.push(`${label}: evidence must reference a dispatch id (bg-N) that exists in the dispatch ledger`);
|
|
15795
|
-
for (const id of ids) {
|
|
15796
|
-
if (referenced.has(id))
|
|
15797
|
-
gaps.push(`${label}: dispatch ${id} is already cited by another subtask — each dispatch belongs to exactly one verdict`);
|
|
15798
|
-
referenced.add(id);
|
|
15799
|
-
const row = rowById.get(id);
|
|
15800
|
-
if (!row)
|
|
15801
|
-
gaps.push(`${label}: dispatch ${id} has no ledger row for this crew (fabricated, stale, or pre-crew)`);
|
|
15802
|
-
else if (verdict === "PASS" && row.event !== "completed") {
|
|
15803
|
-
gaps.push(`${label}: marked PASS but dispatch ${id} ended ${row.event} — a subtask whose attempt and retry both failed must be marked FAIL with both reports visible`);
|
|
15804
|
-
}
|
|
15805
|
-
}
|
|
15806
|
-
if (verdict === "FAIL" && (!Array.isArray(sub?.attempts) || sub.attempts.length < 2)) {
|
|
15807
|
-
gaps.push(`${label}: FAIL requires both failure reports (the attempt and the one retry) in attempts[]`);
|
|
15808
|
-
}
|
|
15809
|
-
});
|
|
15810
|
-
for (const [id, row] of rowById) {
|
|
15811
|
-
if (!referenced.has(id))
|
|
15812
|
-
gaps.push(`dispatch ${id} (${row.event}, ${row.ts}) has no verdict in the report — a dispatched subtask was silently dropped`);
|
|
15813
|
-
}
|
|
15814
|
-
return { ok: gaps.length === 0, gaps, dispatchIds: [...rowById.keys()] };
|
|
14977
|
+
function resolvePinnedDepth(thoughtLevel, family, ladder) {
|
|
14978
|
+
const t = translateDepth(thoughtLevel, family, ladder);
|
|
14979
|
+
if (t.kind === "no-mapping")
|
|
14980
|
+
return { translation: null, findingMessage: t.message };
|
|
14981
|
+
if (t.kind === "not-injected")
|
|
14982
|
+
return { translation: null };
|
|
14983
|
+
return { translation: t };
|
|
15815
14984
|
}
|
|
15816
14985
|
|
|
15817
14986
|
// src/forge-config.ts
|
|
@@ -15842,7 +15011,7 @@ function sanitizeJsonc(text) {
|
|
|
15842
15011
|
if (c === "/" && d === "*") {
|
|
15843
15012
|
out.push(" ", " ");
|
|
15844
15013
|
i += 2;
|
|
15845
|
-
while (i < n && !(text[i] === "*" && i + 1 < n
|
|
15014
|
+
while (i < n && !(text[i] === "*" && i + 1 < n)) {
|
|
15846
15015
|
out.push(text[i] === `
|
|
15847
15016
|
` ? `
|
|
15848
15017
|
` : " ");
|
|
@@ -16033,77 +15202,92 @@ function locateJsonError(text) {
|
|
|
16033
15202
|
ws();
|
|
16034
15203
|
return i >= n ? null : fail(`unexpected token '${text[i]}' after top-level value`);
|
|
16035
15204
|
}
|
|
16036
|
-
i++;
|
|
16037
|
-
continue;
|
|
15205
|
+
i++;
|
|
15206
|
+
continue;
|
|
15207
|
+
}
|
|
15208
|
+
return fail(`unexpected token '${c}'`);
|
|
15209
|
+
}
|
|
15210
|
+
}
|
|
15211
|
+
function parseForgeJsonc(text) {
|
|
15212
|
+
const sanitized = sanitizeJsonc(text);
|
|
15213
|
+
let parsed;
|
|
15214
|
+
try {
|
|
15215
|
+
parsed = JSON.parse(sanitized);
|
|
15216
|
+
} catch {
|
|
15217
|
+
const located = locateJsonError(sanitized);
|
|
15218
|
+
return {
|
|
15219
|
+
ok: false,
|
|
15220
|
+
error: located ?? { message: "invalid JSON", line: 1, column: 1 }
|
|
15221
|
+
};
|
|
15222
|
+
}
|
|
15223
|
+
return { ok: true, config: parsed };
|
|
15224
|
+
}
|
|
15225
|
+
var AGENT_ID_RE = /^[a-z0-9-]+$/;
|
|
15226
|
+
function isRecord(v) {
|
|
15227
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
15228
|
+
}
|
|
15229
|
+
function nonEmptyString(v) {
|
|
15230
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
15231
|
+
}
|
|
15232
|
+
function validateAgentSet(raw) {
|
|
15233
|
+
const findings = [];
|
|
15234
|
+
const agents = {};
|
|
15235
|
+
if (raw === undefined)
|
|
15236
|
+
return { agents, findings };
|
|
15237
|
+
if (!isRecord(raw)) {
|
|
15238
|
+
findings.push({ level: "error", code: "agents-not-object", message: 'forge.json "agents" must be an object mapping agent ids to definitions — the agent set is empty' });
|
|
15239
|
+
return { agents, findings };
|
|
15240
|
+
}
|
|
15241
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
15242
|
+
const label = `agents.${id}`;
|
|
15243
|
+
if (!AGENT_ID_RE.test(id)) {
|
|
15244
|
+
findings.push({ level: "error", code: "agent-id-invalid", message: `${label}: agent id must match [a-z0-9-] — entry skipped` });
|
|
15245
|
+
continue;
|
|
15246
|
+
}
|
|
15247
|
+
if (!isRecord(entry)) {
|
|
15248
|
+
findings.push({ level: "error", code: "agent-not-object", message: `${label}: definition must be an object — entry skipped` });
|
|
15249
|
+
continue;
|
|
15250
|
+
}
|
|
15251
|
+
if (!nonEmptyString(entry.model)) {
|
|
15252
|
+
findings.push({ level: "error", code: "agent-model-missing", message: `${label}.model: a non-empty "provider/model" string is required — entry skipped, siblings still apply` });
|
|
15253
|
+
continue;
|
|
15254
|
+
}
|
|
15255
|
+
const def = { model: entry.model.trim() };
|
|
15256
|
+
if (entry.depths !== undefined) {
|
|
15257
|
+
findings.push({ level: "warn", code: "depths-deprecated", message: `${label}.depths is no longer used (static agents pin one thoughtLevel) — ignored; set "thoughtLevel" instead` });
|
|
15258
|
+
}
|
|
15259
|
+
if (entry.thoughtLevel !== undefined) {
|
|
15260
|
+
if (nonEmptyString(entry.thoughtLevel)) {
|
|
15261
|
+
def.thoughtLevel = entry.thoughtLevel.trim();
|
|
15262
|
+
} else {
|
|
15263
|
+
findings.push({ level: "warn", code: "thoughtlevel-invalid", message: `${label}.thoughtLevel must be a non-empty string — field ignored, the agent still materializes` });
|
|
15264
|
+
}
|
|
15265
|
+
}
|
|
15266
|
+
if (entry.prompt !== undefined) {
|
|
15267
|
+
if (nonEmptyString(entry.prompt)) {
|
|
15268
|
+
def.prompt = entry.prompt;
|
|
15269
|
+
} else {
|
|
15270
|
+
findings.push({ level: "warn", code: "prompt-invalid", message: `${label}.prompt must be a non-empty string — field ignored` });
|
|
15271
|
+
}
|
|
15272
|
+
}
|
|
15273
|
+
if (entry.shape !== undefined) {
|
|
15274
|
+
if (entry.shape === "readonly" || entry.shape === "write") {
|
|
15275
|
+
def.shape = entry.shape;
|
|
15276
|
+
} else {
|
|
15277
|
+
findings.push({ level: "warn", code: "shape-invalid", message: `${label}.shape must be "readonly" or "write" — defaulting to readonly` });
|
|
15278
|
+
}
|
|
15279
|
+
}
|
|
15280
|
+
if (entry.permission !== undefined) {
|
|
15281
|
+
if (isRecord(entry.permission) && Object.values(entry.permission).every((v) => typeof v === "string")) {
|
|
15282
|
+
def.permission = entry.permission;
|
|
15283
|
+
} else {
|
|
15284
|
+
findings.push({ level: "warn", code: "permission-invalid", message: `${label}.permission must map tool names to string values — field ignored` });
|
|
15285
|
+
}
|
|
16038
15286
|
}
|
|
16039
|
-
|
|
16040
|
-
}
|
|
16041
|
-
}
|
|
16042
|
-
function parseForgeJsonc(text) {
|
|
16043
|
-
const sanitized = sanitizeJsonc(text);
|
|
16044
|
-
let parsed;
|
|
16045
|
-
try {
|
|
16046
|
-
parsed = JSON.parse(sanitized);
|
|
16047
|
-
} catch {
|
|
16048
|
-
const located = locateJsonError(sanitized);
|
|
16049
|
-
return {
|
|
16050
|
-
ok: false,
|
|
16051
|
-
error: located ?? { message: "invalid JSON", line: 1, column: 1 }
|
|
16052
|
-
};
|
|
15287
|
+
agents[id] = def;
|
|
16053
15288
|
}
|
|
16054
|
-
|
|
16055
|
-
return { ok: false, error: { message: "forge.json top level must be an object", line: 1, column: 1 } };
|
|
16056
|
-
}
|
|
16057
|
-
const agents = parsed.agents;
|
|
16058
|
-
if (agents === undefined)
|
|
16059
|
-
return { ok: true, config: { agents: {} } };
|
|
16060
|
-
if (typeof agents !== "object" || agents === null || Array.isArray(agents)) {
|
|
16061
|
-
return { ok: false, error: { message: 'forge.json "agents" must be an object mapping agent ids to definitions', line: 1, column: 1 } };
|
|
16062
|
-
}
|
|
16063
|
-
return { ok: true, config: { agents } };
|
|
16064
|
-
}
|
|
16065
|
-
function forgeRecipe(input) {
|
|
16066
|
-
const suggested = input.detectedIdentities[0] ?? "provider/model";
|
|
16067
|
-
const identityLines = input.detectedIdentities.length > 0 ? input.detectedIdentities.map((id) => ` - ${id}`).join(`
|
|
16068
|
-
`) : " (none detected — check the provider section of the host's opencode config)";
|
|
16069
|
-
const template = `{
|
|
16070
|
-
// forge dispatch agents — save the file; changes apply on the next dispatch, no restart
|
|
16071
|
-
"agents": {
|
|
16072
|
-
"research": {
|
|
16073
|
-
"model": "${suggested}", // exact "provider/model" string, pick from the detected list above
|
|
16074
|
-
"depths": ["low", "medium"], // first entry is the default; canonical words: none, low, medium, high, max — or the model's native level names, passed through verbatim
|
|
16075
|
-
// "prompt": "optional role prompt riding inside the discipline wrapper",
|
|
16076
|
-
// "shape": "write", // default readonly denies mutating tools; write agents need this
|
|
16077
|
-
// "permission": { "bash": "deny" } // optional override of the shape-derived permission
|
|
16078
|
-
}
|
|
16079
|
-
}
|
|
16080
|
-
}`;
|
|
16081
|
-
return [
|
|
16082
|
-
"[forge dispatch onboarding recipe]",
|
|
16083
|
-
'The dispatch agent you called is pinned to the placeholder model "Local/GPT Luna" — forge.json is not configured. Steps:',
|
|
16084
|
-
"",
|
|
16085
|
-
"1. Detected configured identities (use one as the exact model string):",
|
|
16086
|
-
identityLines,
|
|
16087
|
-
"",
|
|
16088
|
-
"2. Create ONE of these files (project-level wins; a single file fully applies — never merged):",
|
|
16089
|
-
` - ${input.projectPath} (project-level, versionable, team-shared)`,
|
|
16090
|
-
` - ${input.globalPath} (global)`,
|
|
16091
|
-
"",
|
|
16092
|
-
"3. Copy-paste template (JSONC — comments allowed) and edit model/depths:",
|
|
16093
|
-
"",
|
|
16094
|
-
template,
|
|
16095
|
-
"",
|
|
16096
|
-
'4. Verify: call the forge_dispatch tool again with agent "research" and a depth from its depths — the report shows the actual model and the depth translation.',
|
|
16097
|
-
"",
|
|
16098
|
-
'5. Extending: more agents are more entries under "agents"; agents that edit files need "shape": "write"; an explicit "permission" map overrides the shape-derived defaults — "task" is always denied, recursive dispatch stays impossible.'
|
|
16099
|
-
].join(`
|
|
16100
|
-
`);
|
|
15289
|
+
return { agents, findings };
|
|
16101
15290
|
}
|
|
16102
|
-
var PLACEHOLDER_IDENTITY = "Local/GPT Luna";
|
|
16103
|
-
var SEED_AGENTS = {
|
|
16104
|
-
research: { model: PLACEHOLDER_IDENTITY, depths: ["low", "medium"], shape: "readonly" },
|
|
16105
|
-
review: { model: PLACEHOLDER_IDENTITY, depths: ["medium", "high", "max"], shape: "readonly" }
|
|
16106
|
-
};
|
|
16107
15291
|
function forgeConfigPaths(opts) {
|
|
16108
15292
|
const sep2 = opts.projectDir?.includes("\\") && !opts.projectDir?.includes("/") ? "\\" : "/";
|
|
16109
15293
|
const project = opts.projectDir ? `${opts.projectDir}${sep2}.opencode${sep2}forge.json` : "";
|
|
@@ -16136,19 +15320,30 @@ function createForgeConfigLoader(deps) {
|
|
|
16136
15320
|
let result;
|
|
16137
15321
|
if (!parsed.ok) {
|
|
16138
15322
|
result = {
|
|
16139
|
-
agents: {
|
|
16140
|
-
source
|
|
16141
|
-
path
|
|
15323
|
+
agents: {},
|
|
15324
|
+
source,
|
|
15325
|
+
path,
|
|
16142
15326
|
findings: [
|
|
16143
15327
|
{
|
|
16144
15328
|
level: "error",
|
|
16145
15329
|
code: "config-parse-error",
|
|
16146
|
-
message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} —
|
|
15330
|
+
message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — the agent set is empty until the file parses`
|
|
16147
15331
|
}
|
|
16148
15332
|
]
|
|
16149
15333
|
};
|
|
16150
15334
|
} else {
|
|
16151
|
-
|
|
15335
|
+
const top = parsed.config;
|
|
15336
|
+
if (top !== undefined && !isRecord(top)) {
|
|
15337
|
+
result = {
|
|
15338
|
+
agents: {},
|
|
15339
|
+
source,
|
|
15340
|
+
path,
|
|
15341
|
+
findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — the agent set is empty` }]
|
|
15342
|
+
};
|
|
15343
|
+
} else {
|
|
15344
|
+
const validated = validateAgentSet(top === undefined ? undefined : top.agents);
|
|
15345
|
+
result = { agents: validated.agents, source, path, findings: validated.findings };
|
|
15346
|
+
}
|
|
16152
15347
|
}
|
|
16153
15348
|
cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
16154
15349
|
return result;
|
|
@@ -16161,104 +15356,18 @@ function createForgeConfigLoader(deps) {
|
|
|
16161
15356
|
const global = paths.global ? readCandidate(paths.global, "global") : null;
|
|
16162
15357
|
if (global)
|
|
16163
15358
|
return global;
|
|
16164
|
-
return {
|
|
16165
|
-
agents: { ...SEED_AGENTS },
|
|
16166
|
-
source: "seed",
|
|
16167
|
-
path: null,
|
|
16168
|
-
findings: [
|
|
16169
|
-
{
|
|
16170
|
-
level: "notice",
|
|
16171
|
-
code: "dispatch-unconfigured",
|
|
16172
|
-
message: "forge dispatch is unconfigured (no forge.json found): every forge_dispatch call will fail with the configuration recipe. Create .opencode/forge.json (project) or ~/.config/opencode/forge.json (global), or ask your session AI to configure dispatch — the recipe in the dispatch error tells it exactly what to write."
|
|
16173
|
-
}
|
|
16174
|
-
]
|
|
16175
|
-
};
|
|
15359
|
+
return { agents: {}, source: "none", path: null, findings: [] };
|
|
16176
15360
|
}
|
|
16177
15361
|
};
|
|
16178
15362
|
}
|
|
16179
15363
|
|
|
16180
|
-
// src/models-dev.ts
|
|
16181
|
-
import { mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
|
|
16182
|
-
import { join as join4 } from "node:path";
|
|
16183
|
-
import { tmpdir as tmpdir2 } from "node:os";
|
|
16184
|
-
var DEFAULT_MODELS_DEV_URL = "https://models.dev/api.json";
|
|
16185
|
-
function modelsDevCacheDir(base) {
|
|
16186
|
-
return join4(base ?? join4(tmpdir2(), "opencode-forge"), "models-dev");
|
|
16187
|
-
}
|
|
16188
|
-
function namedLevels(rows) {
|
|
16189
|
-
const out = [];
|
|
16190
|
-
for (const r of rows) {
|
|
16191
|
-
if (r && typeof r === "object" && r.type === "effort") {
|
|
16192
|
-
const values = r.values;
|
|
16193
|
-
if (Array.isArray(values)) {
|
|
16194
|
-
for (const v of values)
|
|
16195
|
-
if (typeof v === "string")
|
|
16196
|
-
out.push(v);
|
|
16197
|
-
}
|
|
16198
|
-
}
|
|
16199
|
-
}
|
|
16200
|
-
return out;
|
|
16201
|
-
}
|
|
16202
|
-
function reduceModelsDev(raw) {
|
|
16203
|
-
const catalog = { providers: {} };
|
|
16204
|
-
const providers = raw?.constructor === Object ? raw : null;
|
|
16205
|
-
if (!providers)
|
|
16206
|
-
return catalog;
|
|
16207
|
-
for (const [providerID, provider] of Object.entries(providers)) {
|
|
16208
|
-
const models = provider.models;
|
|
16209
|
-
if (!models || typeof models !== "object")
|
|
16210
|
-
continue;
|
|
16211
|
-
const out = { models: {} };
|
|
16212
|
-
for (const [modelID, model] of Object.entries(models)) {
|
|
16213
|
-
const m = model;
|
|
16214
|
-
const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
|
|
16215
|
-
const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
|
|
16216
|
-
out.models[modelID] = {
|
|
16217
|
-
...options ? { reasoningOptions: options } : {},
|
|
16218
|
-
...cost ? { cost } : {}
|
|
16219
|
-
};
|
|
16220
|
-
}
|
|
16221
|
-
catalog.providers[providerID] = out;
|
|
16222
|
-
}
|
|
16223
|
-
return catalog;
|
|
16224
|
-
}
|
|
16225
|
-
async function loadModelsDevSnapshot(opts = {}) {
|
|
16226
|
-
const url2 = opts.url ?? process.env.OPENCODE_MODELS_URL ?? DEFAULT_MODELS_DEV_URL;
|
|
16227
|
-
const cacheDir = modelsDevCacheDir(opts.cacheDir);
|
|
16228
|
-
const cacheFile = join4(cacheDir, "snapshot.json");
|
|
16229
|
-
const fetcher = opts.fetcher;
|
|
16230
|
-
if (fetcher) {
|
|
16231
|
-
try {
|
|
16232
|
-
const res = await fetcher(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
16233
|
-
if (res.ok)
|
|
16234
|
-
return { catalog: reduceModelsDev(JSON.parse(await res.text())), degraded: false, source: url2 };
|
|
16235
|
-
} catch {}
|
|
16236
|
-
return { catalog: { providers: {} }, degraded: true, source: "fetch-failed" };
|
|
16237
|
-
}
|
|
16238
|
-
try {
|
|
16239
|
-
const res = await fetch(url2, { signal: AbortSignal.timeout(opts.timeoutMs ?? 8000) });
|
|
16240
|
-
if (res.ok) {
|
|
16241
|
-
const catalog = reduceModelsDev(JSON.parse(await res.text()));
|
|
16242
|
-
try {
|
|
16243
|
-
mkdirSync5(cacheDir, { recursive: true });
|
|
16244
|
-
writeFileSync5(cacheFile, JSON.stringify(catalog));
|
|
16245
|
-
} catch {}
|
|
16246
|
-
return { catalog, degraded: false, source: url2 };
|
|
16247
|
-
}
|
|
16248
|
-
} catch {}
|
|
16249
|
-
try {
|
|
16250
|
-
const cached2 = JSON.parse(readFileSync8(cacheFile, "utf8"));
|
|
16251
|
-
if (cached2 && typeof cached2 === "object" && cached2.providers)
|
|
16252
|
-
return { catalog: cached2, degraded: true, source: "cache" };
|
|
16253
|
-
} catch {}
|
|
16254
|
-
return { catalog: { providers: {} }, degraded: true, source: "unavailable" };
|
|
16255
|
-
}
|
|
16256
|
-
|
|
16257
15364
|
// plugin.ts
|
|
16258
15365
|
var FORGE_AGENT = "forge";
|
|
16259
15366
|
var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
|
|
16260
15367
|
|
|
16261
|
-
Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it
|
|
15368
|
+
Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it.
|
|
15369
|
+
|
|
15370
|
+
Subagent dispatch (task tool): when the work suits a scoped worker, prefer a configured \`forge-*\` subagent whose description matches the job. If none matches, say so plainly to the user and dispatch through the native task channel instead.`;
|
|
16262
15371
|
var PLAN_COMMAND_TEMPLATE = [
|
|
16263
15372
|
'(forge plan harness routing. Argument: "$ARGUMENTS")',
|
|
16264
15373
|
"",
|
|
@@ -16317,22 +15426,14 @@ function effectiveWorktree(worktree, fallback) {
|
|
|
16317
15426
|
}
|
|
16318
15427
|
var sessions = new Map;
|
|
16319
15428
|
var forgeDisabled = false;
|
|
16320
|
-
var dispatchDisabled = false;
|
|
16321
|
-
var dispatchMaxConcurrent = 4;
|
|
16322
|
-
var dispatchTimeoutMs = 600000;
|
|
16323
|
-
var dispatchBase = null;
|
|
16324
15429
|
var forgeLoader = null;
|
|
16325
15430
|
var forgeLoaderDir = null;
|
|
16326
|
-
var forgeConfigState = null;
|
|
16327
|
-
var lastDetectedIdentities = [];
|
|
16328
|
-
var activeDispatchEngine = null;
|
|
16329
|
-
var dispatchLogDir = join5(tmpdir3(), "opencode-forge", "dispatch");
|
|
16330
|
-
var dispatchLedger = createDispatchLedger(join5(dispatchLogDir, "ledger.jsonl"));
|
|
16331
|
-
var activeDispatchRegistry = null;
|
|
16332
|
-
var dispatchClient = null;
|
|
16333
|
-
var dispatchWakeTimers = new Map;
|
|
16334
15431
|
var crews = new Map;
|
|
16335
|
-
var
|
|
15432
|
+
var modelsDevCatalog = null;
|
|
15433
|
+
var depthFindingNotes = new Map;
|
|
15434
|
+
var crewCommandSnapshot = null;
|
|
15435
|
+
var materializedSnapshot = {};
|
|
15436
|
+
var sessionDepths = new Map;
|
|
16336
15437
|
var OPENAI_FAMILY = new Set(["openai", "opencode", "openrouter", "groq", "xai", "azure", "github-copilot", "deepseek", "together", "vercel", "opus"]);
|
|
16337
15438
|
var ANTHROPIC_FAMILY = new Set(["anthropic"]);
|
|
16338
15439
|
var ZAI_FAMILY = new Set(["zai", "zai-coding-plan"]);
|
|
@@ -16345,32 +15446,6 @@ function dispatchProviderFamily(providerID) {
|
|
|
16345
15446
|
return "zai";
|
|
16346
15447
|
return null;
|
|
16347
15448
|
}
|
|
16348
|
-
function queueDispatchDepth(sessionID, translation) {
|
|
16349
|
-
const key = (t) => t.kind === "verbatim" ? `verbatim:${t.word}` : t.kind === "tiered" ? `tiered:${t.budgetTokens}` : t.kind;
|
|
16350
|
-
const existing = dispatchDepths.get(sessionID);
|
|
16351
|
-
if (existing)
|
|
16352
|
-
return key(existing) === key(translation);
|
|
16353
|
-
dispatchDepths.set(sessionID, translation);
|
|
16354
|
-
return true;
|
|
16355
|
-
}
|
|
16356
|
-
function configuredIdentitiesOf(cfg, catalog) {
|
|
16357
|
-
const providers = cfg.provider ?? {};
|
|
16358
|
-
const identities = [];
|
|
16359
|
-
for (const [providerID, provider] of Object.entries(providers)) {
|
|
16360
|
-
const modelKeys = provider?.models ? Object.keys(provider.models) : null;
|
|
16361
|
-
if (modelKeys && modelKeys.length > 0) {
|
|
16362
|
-
for (const m of modelKeys)
|
|
16363
|
-
identities.push(`${providerID}/${m}`);
|
|
16364
|
-
} else {
|
|
16365
|
-
const catModels = catalog.providers?.[providerID]?.models ?? {};
|
|
16366
|
-
const ids = Object.keys(catModels);
|
|
16367
|
-
if (ids.length > 0)
|
|
16368
|
-
for (const m of ids)
|
|
16369
|
-
identities.push(`${providerID}/${m}`);
|
|
16370
|
-
}
|
|
16371
|
-
}
|
|
16372
|
-
return identities;
|
|
16373
|
-
}
|
|
16374
15449
|
var jobsMode = "auto";
|
|
16375
15450
|
var jobsKeepBuiltinShell = false;
|
|
16376
15451
|
var jobsSurviveMode = "never";
|
|
@@ -16426,9 +15501,9 @@ var jobLogDir = jobsLogDir();
|
|
|
16426
15501
|
var jobLedgerPath = join5(jobLogDir, "ledger.jsonl");
|
|
16427
15502
|
function jobLedgerSink(entry) {
|
|
16428
15503
|
try {
|
|
16429
|
-
|
|
15504
|
+
mkdirSync5(jobLogDir, { recursive: true });
|
|
16430
15505
|
if (existsSync3(jobLedgerPath) && statSync4(jobLedgerPath).size > 1e6)
|
|
16431
|
-
|
|
15506
|
+
writeFileSync5(jobLedgerPath, "");
|
|
16432
15507
|
appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
|
|
16433
15508
|
`);
|
|
16434
15509
|
} catch {}
|
|
@@ -16489,7 +15564,7 @@ function readPlanDir(worktree) {
|
|
|
16489
15564
|
const dir = planDirOf(worktree);
|
|
16490
15565
|
if (!existsSync3(dir))
|
|
16491
15566
|
return [];
|
|
16492
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text:
|
|
15567
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
|
|
16493
15568
|
}
|
|
16494
15569
|
function ensureSession(sessionID, worktree) {
|
|
16495
15570
|
const existing = sessions.get(sessionID);
|
|
@@ -16506,7 +15581,7 @@ function worktreeFor(context) {
|
|
|
16506
15581
|
}
|
|
16507
15582
|
function resolveActivePlan(state) {
|
|
16508
15583
|
if (state.planPath && existsSync3(state.planPath)) {
|
|
16509
|
-
const doc2 = parsePlanLoose(
|
|
15584
|
+
const doc2 = parsePlanLoose(readFileSync8(state.planPath, "utf8"));
|
|
16510
15585
|
if (doc2 && !isTerminal(doc2.status))
|
|
16511
15586
|
return { path: state.planPath, doc: doc2 };
|
|
16512
15587
|
state.planPath = undefined;
|
|
@@ -16548,12 +15623,12 @@ var planWriteTool = tool({
|
|
|
16548
15623
|
throw new PlanError(`A plan in ${active.doc.status} state is already active (${relFrom(state.worktree, active.path)}). Finish it with plan_close, or /plan discard it before planning something new.`);
|
|
16549
15624
|
} else {
|
|
16550
15625
|
const dir = planDirOf(state.worktree);
|
|
16551
|
-
|
|
15626
|
+
mkdirSync5(dir, { recursive: true });
|
|
16552
15627
|
path = join5(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
|
|
16553
15628
|
mode = "created";
|
|
16554
15629
|
}
|
|
16555
15630
|
const text = renderPlan(args, now, created);
|
|
16556
|
-
|
|
15631
|
+
writeFileSync5(path, text);
|
|
16557
15632
|
state.planPath = path;
|
|
16558
15633
|
const doc2 = parsePlan(text);
|
|
16559
15634
|
context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
|
|
@@ -16581,8 +15656,8 @@ var planTickTool = tool({
|
|
|
16581
15656
|
if (active.doc.status !== "approved") {
|
|
16582
15657
|
throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
|
|
16583
15658
|
}
|
|
16584
|
-
const next = tickTask(
|
|
16585
|
-
|
|
15659
|
+
const next = tickTask(readFileSync8(active.path, "utf8"), args.n, nowIso());
|
|
15660
|
+
writeFileSync5(active.path, next);
|
|
16586
15661
|
const doc2 = parsePlan(next);
|
|
16587
15662
|
const p = progressOf(doc2);
|
|
16588
15663
|
context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
|
|
@@ -16609,7 +15684,7 @@ var planApproveTool = tool({
|
|
|
16609
15684
|
throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
|
|
16610
15685
|
}
|
|
16611
15686
|
await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
|
|
16612
|
-
|
|
15687
|
+
writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "approved", nowIso()));
|
|
16613
15688
|
context.metadata({ title: `Plan approved: ${active.doc.goal}` });
|
|
16614
15689
|
return {
|
|
16615
15690
|
title: "plan approved",
|
|
@@ -16642,7 +15717,7 @@ var planCloseTool = tool({
|
|
|
16642
15717
|
Fix the implementation and retry, or revise the plan first.`);
|
|
16643
15718
|
}
|
|
16644
15719
|
await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
|
|
16645
|
-
|
|
15720
|
+
writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "done", nowIso()));
|
|
16646
15721
|
context.metadata({ title: `Plan done: ${active.doc.goal}` });
|
|
16647
15722
|
return {
|
|
16648
15723
|
title: "plan done",
|
|
@@ -16660,7 +15735,7 @@ var planDiscardTool = tool({
|
|
|
16660
15735
|
const active = resolveActivePlan(state);
|
|
16661
15736
|
if (!active)
|
|
16662
15737
|
throw new PlanError("No plan to abandon in this workspace.");
|
|
16663
|
-
|
|
15738
|
+
writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "abandoned", nowIso()));
|
|
16664
15739
|
state.planPath = undefined;
|
|
16665
15740
|
context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
|
|
16666
15741
|
return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
|
|
@@ -16673,11 +15748,11 @@ function readGoalDir(worktree) {
|
|
|
16673
15748
|
const dir = goalDirOf(worktree);
|
|
16674
15749
|
if (!existsSync3(dir))
|
|
16675
15750
|
return [];
|
|
16676
|
-
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text:
|
|
15751
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join5(dir, name), "utf8") }));
|
|
16677
15752
|
}
|
|
16678
15753
|
function resolveSessionGoal(state) {
|
|
16679
15754
|
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
16680
|
-
const doc2 = parseGoalLoose(
|
|
15755
|
+
const doc2 = parseGoalLoose(readFileSync8(state.goalPath, "utf8"));
|
|
16681
15756
|
if (doc2 && !isGoalTerminal(doc2.status))
|
|
16682
15757
|
return { path: state.goalPath, doc: doc2 };
|
|
16683
15758
|
state.goalPath = undefined;
|
|
@@ -16781,7 +15856,7 @@ var goalWriteTool = tool({
|
|
|
16781
15856
|
await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
|
|
16782
15857
|
}
|
|
16783
15858
|
const dir = goalDirOf(state.worktree);
|
|
16784
|
-
|
|
15859
|
+
mkdirSync5(dir, { recursive: true });
|
|
16785
15860
|
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
|
|
16786
15861
|
const path = join5(dir, name);
|
|
16787
15862
|
const text = renderGoal(input, {
|
|
@@ -16823,7 +15898,7 @@ var goalCheckTool = tool({
|
|
|
16823
15898
|
o.index = selected[i].n;
|
|
16824
15899
|
});
|
|
16825
15900
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16826
|
-
const next = appendCheckLog(
|
|
15901
|
+
const next = appendCheckLog(readFileSync8(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
16827
15902
|
atomicWrite(goal.path, next);
|
|
16828
15903
|
const ok = outcomesAllOk(outcomes);
|
|
16829
15904
|
context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
|
|
@@ -16857,7 +15932,7 @@ var goalCompleteTool = tool({
|
|
|
16857
15932
|
const failures = outcomes.filter((o) => !o.ok);
|
|
16858
15933
|
if (failures.length > 0) {
|
|
16859
15934
|
const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16860
|
-
atomicWrite(goal.path, appendCheckLog(
|
|
15935
|
+
atomicWrite(goal.path, appendCheckLog(readFileSync8(goal.path, "utf8"), runId2, outcomes, nowIso()));
|
|
16861
15936
|
throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
|
|
16862
15937
|
${formatOutcomes(failures)}
|
|
16863
15938
|
Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
|
|
@@ -16870,7 +15945,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
|
|
|
16870
15945
|
}
|
|
16871
15946
|
await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
|
|
16872
15947
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16873
|
-
let text = appendCheckLog(
|
|
15948
|
+
let text = appendCheckLog(readFileSync8(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
16874
15949
|
text = transitionGoal(text, "completed", nowIso());
|
|
16875
15950
|
atomicWrite(goal.path, text);
|
|
16876
15951
|
context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
|
|
@@ -16892,7 +15967,7 @@ var goalPauseTool = tool({
|
|
|
16892
15967
|
throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
|
|
16893
15968
|
}
|
|
16894
15969
|
const stopReason = args.blocker ? "blocker" : "user";
|
|
16895
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15970
|
+
atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
|
|
16896
15971
|
engineForgetSession(context.sessionID);
|
|
16897
15972
|
context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
|
|
16898
15973
|
return {
|
|
@@ -16922,7 +15997,7 @@ var goalResumeTool = tool({
|
|
|
16922
15997
|
}
|
|
16923
15998
|
const promoting = goal.doc.status === "queued";
|
|
16924
15999
|
await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
|
|
16925
|
-
let text =
|
|
16000
|
+
let text = readFileSync8(goal.path, "utf8");
|
|
16926
16001
|
if (args.addTurns)
|
|
16927
16002
|
text = bumpBudget(text, args.addTurns, nowIso());
|
|
16928
16003
|
text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
|
|
@@ -16948,7 +16023,7 @@ var goalDiscardTool = tool({
|
|
|
16948
16023
|
if (!goal)
|
|
16949
16024
|
throw new GoalError("No goal to discard in this workspace.");
|
|
16950
16025
|
await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
|
|
16951
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16026
|
+
atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "abandoned", nowIso()));
|
|
16952
16027
|
state.goalPath = undefined;
|
|
16953
16028
|
engineForgetSession(context.sessionID);
|
|
16954
16029
|
context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
|
|
@@ -17145,226 +16220,47 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
17145
16220
|
};
|
|
17146
16221
|
}
|
|
17147
16222
|
});
|
|
17148
|
-
|
|
17149
|
-
|
|
17150
|
-
if (Number.isFinite(env) && env >= 0)
|
|
17151
|
-
return env;
|
|
17152
|
-
return idleDriven ? IDLE_DEBOUNCE_MS + 50 : 1500;
|
|
17153
|
-
}
|
|
17154
|
-
function scheduleDispatchWake(sessionID, idleDriven) {
|
|
17155
|
-
if (dispatchDisabled || forgeDisabled)
|
|
17156
|
-
return;
|
|
17157
|
-
if (dispatchWakeTimers.has(sessionID))
|
|
17158
|
-
return;
|
|
17159
|
-
const t = setTimeout(() => {
|
|
17160
|
-
dispatchWakeTimers.delete(sessionID);
|
|
17161
|
-
injectDispatchBrief(sessionID);
|
|
17162
|
-
}, dispatchWakeDelay(idleDriven));
|
|
17163
|
-
dispatchWakeTimers.set(sessionID, t);
|
|
17164
|
-
}
|
|
17165
|
-
function dispatchBriefText(entries) {
|
|
17166
|
-
const lines = [`[forge:dispatch-complete] ${entries.length} background dispatch(es) reached terminal state:`];
|
|
17167
|
-
for (const e of entries) {
|
|
17168
|
-
lines.push("", `- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not yet started)"}`);
|
|
17169
|
-
const r = e.result;
|
|
17170
|
-
if (r !== undefined) {
|
|
17171
|
-
lines.push(` result: ${JSON.stringify(r, null, 1).replace(/\n/g, `
|
|
17172
|
-
`).slice(0, 2000)}`);
|
|
17173
|
-
}
|
|
17174
|
-
}
|
|
17175
|
-
return lines.join(`
|
|
17176
|
-
`);
|
|
17177
|
-
}
|
|
17178
|
-
async function injectDispatchBrief(sessionID) {
|
|
17179
|
-
const reg = activeDispatchRegistry;
|
|
17180
|
-
if (!reg)
|
|
17181
|
-
return;
|
|
17182
|
-
const c0 = dispatchClient;
|
|
17183
|
-
if (typeof c0?.session.status === "function") {
|
|
17184
|
-
try {
|
|
17185
|
-
const st = await c0.session.status({ path: { id: sessionID } });
|
|
17186
|
-
const t = st?.[sessionID]?.type;
|
|
17187
|
-
if (t && t !== "idle")
|
|
17188
|
-
return;
|
|
17189
|
-
} catch {
|
|
17190
|
-
return;
|
|
17191
|
-
}
|
|
17192
|
-
}
|
|
17193
|
-
const entries = reg.takeUndelivered(sessionID);
|
|
17194
|
-
if (entries.length === 0)
|
|
17195
|
-
return;
|
|
17196
|
-
const c = dispatchClient;
|
|
17197
|
-
const send = c?.session.promptAsync ?? c?.session.prompt;
|
|
17198
|
-
if (typeof send !== "function")
|
|
17199
|
-
return;
|
|
17200
|
-
try {
|
|
17201
|
-
await send.call(c.session, { path: { id: sessionID }, body: { parts: [{ type: "text", text: dispatchBriefText(entries) }] } });
|
|
17202
|
-
} catch {
|
|
17203
|
-
for (const e of entries)
|
|
17204
|
-
e.delivered = false;
|
|
17205
|
-
}
|
|
17206
|
-
}
|
|
17207
|
-
var forgeDispatchTool = tool({
|
|
17208
|
-
description: "Dispatch a scoped task to a worker subagent defined in forge.json. Takes {prompt, agent, depth}: agent names a dispatch agent (see forge_dispatch_config for the effective set); the agent's model is pinned by its definition — no fallback ever. depth comes from the canonical metalanguage (none, low, medium, high, max; default = the agent's first entry) or the model's native level names verbatim (escape hatch); the ONLY validation is membership in the agent's depths — the provider is the final judge and its raw errors flow back. The report discloses the depth translation (canonical -> native). Background mode targets live TUI sessions; under `opencode run` prefer sync. The result reports the actual model/depth, real token counts, a self-computed cost (null when unpriced), and the worker's concluding report. Refuses while a plan draft is active.",
|
|
17209
|
-
args: {
|
|
17210
|
-
prompt: tool.schema.string().describe("Complete, self-contained task for the worker (it cannot see this conversation)"),
|
|
17211
|
-
agent: tool.schema.string().describe("Dispatch agent id from forge.json (e.g. research, review, or a custom id; legacy inline tier ids still work when configured)"),
|
|
17212
|
-
depth: tool.schema.string().optional().describe("Reasoning depth: none | low | medium | high | max (canonical) or the model's native level name verbatim; omit to use the agent's first depths entry"),
|
|
17213
|
-
background: tool.schema.boolean().optional().describe("Run in the background: returns {dispatchId, resolved, queuedAt} immediately; the full result arrives later as a coalesced [forge:dispatch-complete] brief when the session is idle. Background mode targets live TUI sessions — under `opencode run` the session ends before any brief, so completions are ledger-only (read them via forge_dispatch_list) and sync mode is preferred.")
|
|
17214
|
-
},
|
|
17215
|
-
execute: async (args, context) => {
|
|
17216
|
-
const state = stateForBan(context.sessionID);
|
|
17217
|
-
const active = state ? resolveActivePlan(state) : null;
|
|
17218
|
-
if (active && active.doc.status === "draft") {
|
|
17219
|
-
throw new Error("[forge] A plan is in draft; dispatching workers is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17220
|
-
}
|
|
17221
|
-
if (!args.prompt?.trim())
|
|
17222
|
-
throw new Error("forge_dispatch requires a non-empty prompt.");
|
|
17223
|
-
const engine = activeDispatchEngine;
|
|
17224
|
-
if (!engine)
|
|
17225
|
-
throw new Error("[forge] dispatch engine unavailable (disabled by dispatch.disable, or config still loading).");
|
|
17226
|
-
try {
|
|
17227
|
-
const r = await engine.dispatch({ prompt: args.prompt, agent: args.agent, ...args.depth !== undefined ? { depth: args.depth } : {} }, context.sessionID, args.background === true ? { background: true } : undefined);
|
|
17228
|
-
if ("dispatchId" in r) {
|
|
17229
|
-
context.metadata({ title: `dispatch ${r.agent} queued (${r.dispatchId})` });
|
|
17230
|
-
return {
|
|
17231
|
-
title: `dispatch ${r.agent} queued → ${r.resolved}${r.depth ? ` @${r.depth}` : ""}`,
|
|
17232
|
-
output: [
|
|
17233
|
-
`[forge:dispatch] accepted in the background: ${r.dispatchId}`,
|
|
17234
|
-
`agent: ${r.agent} resolved: ${r.resolved}${r.depth ? ` depth: ${r.depth}` : ""}`,
|
|
17235
|
-
`queuedAt: ${r.queuedAt}`,
|
|
17236
|
-
"",
|
|
17237
|
-
"The full result (tokens/cost/report — or the timeout/error report) arrives as a [forge:dispatch-complete] brief when this session is next idle. Track progress any time with forge_dispatch_list; cancel with forge_dispatch_kill."
|
|
17238
|
-
].join(`
|
|
17239
|
-
`)
|
|
17240
|
-
};
|
|
17241
|
-
}
|
|
17242
|
-
const tok = r.tokens;
|
|
17243
|
-
const costLine = r.costUsd !== null && r.costUsd !== undefined ? `$${r.costUsd.toFixed(6)}${r.priceSnapshot ? ` (${r.priceSnapshot})` : ""}` : `unpriced${r.costNote ? ` — ${r.costNote}` : ""}`;
|
|
17244
|
-
context.metadata({ title: `dispatch ${r.agent}: ${r.actual.model} @ ${r.actual.depth}` });
|
|
17245
|
-
return {
|
|
17246
|
-
title: `dispatch ${r.agent} → ${r.actual.model} @${r.actual.depth}`,
|
|
17247
|
-
output: [
|
|
17248
|
-
`[forge:dispatch] completed in ${r.durationMs}ms`,
|
|
17249
|
-
`agent: ${r.agent} model: ${r.actual.model} depth: ${r.actual.depth}${r.requested.depth !== undefined && r.requested.depth !== r.actual.depth ? ` (requested ${r.requested.depth})` : ""}`,
|
|
17250
|
-
`session: ${r.sessionID}`,
|
|
17251
|
-
`tokens: input ${tok.input ?? 0}, output ${tok.output ?? 0}, reasoning ${tok.reasoning ?? 0}, cache r/w ${tok.cache?.read ?? 0}/${tok.cache?.write ?? 0}`,
|
|
17252
|
-
`cost: ${costLine}`,
|
|
17253
|
-
`depthTranslation: ${r.depthTranslation}`,
|
|
17254
|
-
"",
|
|
17255
|
-
"Worker report:",
|
|
17256
|
-
r.text || "(empty worker report)"
|
|
17257
|
-
].join(`
|
|
17258
|
-
`)
|
|
17259
|
-
};
|
|
17260
|
-
} catch (err) {
|
|
17261
|
-
if (err instanceof DispatchError && err.code === "pin-unavailable") {
|
|
17262
|
-
throw new Error(`[forge:dispatch:pin-unavailable] ${err.message}
|
|
17263
|
-
|
|
17264
|
-
${dispatchRecipeText()}`);
|
|
17265
|
-
}
|
|
17266
|
-
if (err instanceof DispatchError)
|
|
17267
|
-
throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
|
|
17268
|
-
throw err;
|
|
17269
|
-
}
|
|
17270
|
-
}
|
|
17271
|
-
});
|
|
17272
|
-
function dispatchRecipeText() {
|
|
17273
|
-
const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
17274
|
-
return forgeRecipe({
|
|
17275
|
-
detectedIdentities: lastDetectedIdentities,
|
|
17276
|
-
projectPath: paths.project || "(project .opencode/forge.json)",
|
|
17277
|
-
globalPath: paths.global
|
|
17278
|
-
});
|
|
17279
|
-
}
|
|
17280
|
-
var forgeDispatchConfigTool = tool({
|
|
17281
|
-
description: "Read the effective forge dispatch configuration: {agents, knobs, findings}. agents mirrors the winning forge.json (project .opencode/forge.json > global ~/.config/opencode/forge.json > built-in seed) exactly — same keys, same shapes, writable back as valid forge.json agents content. knobs are the inline dispatch options {timeoutMs, maxConcurrent}. findings carry config state (config-parse-error, dispatch-unconfigured notice). Re-reads the file (mtime-cached) on every call, so edits are reflected immediately.",
|
|
17282
|
-
args: {},
|
|
17283
|
-
execute: async (_args, context) => {
|
|
17284
|
-
const loaded = forgeLoader?.load() ?? { agents: {}, source: "seed", path: null, findings: [] };
|
|
17285
|
-
context.metadata({ title: `dispatch config (${loaded.source})` });
|
|
17286
|
-
return {
|
|
17287
|
-
title: `dispatch config (${loaded.source}${loaded.path ? `: ${loaded.path}` : ""})`,
|
|
17288
|
-
output: JSON.stringify({
|
|
17289
|
-
agents: loaded.agents,
|
|
17290
|
-
knobs: { timeoutMs: dispatchTimeoutMs, maxConcurrent: dispatchMaxConcurrent },
|
|
17291
|
-
findings: loaded.findings
|
|
17292
|
-
}, null, 2)
|
|
17293
|
-
};
|
|
17294
|
-
}
|
|
17295
|
-
});
|
|
17296
|
-
var forgeDispatchListTool = tool({
|
|
17297
|
-
description: "List background forge_dispatch activity: in-flight dispatches (with dispatchIds) and recent terminal results (completed/timeout/error, full report objects). Use it to recover dispatch state after context compaction or to check on a background dispatch instead of waiting.",
|
|
17298
|
-
args: {},
|
|
17299
|
-
execute: async () => {
|
|
17300
|
-
const reg = activeDispatchRegistry;
|
|
17301
|
-
if (!reg)
|
|
17302
|
-
throw new Error("[forge] no background dispatches have been submitted.");
|
|
17303
|
-
const l = reg.list();
|
|
17304
|
-
if (l.inFlight.length === 0 && l.terminal.length === 0) {
|
|
17305
|
-
return { title: "no background dispatches", output: "[forge:dispatch-list] no background dispatches recorded in this session." };
|
|
17306
|
-
}
|
|
17307
|
-
const lines = [];
|
|
17308
|
-
if (l.inFlight.length > 0) {
|
|
17309
|
-
lines.push("In flight:");
|
|
17310
|
-
for (const e of l.inFlight) {
|
|
17311
|
-
lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state} · queued ${e.queuedAt} · session ${e.sessionID || "(starting)"}`);
|
|
17312
|
-
}
|
|
17313
|
-
}
|
|
17314
|
-
if (l.terminal.length > 0) {
|
|
17315
|
-
lines.push("", "Recent terminal results:");
|
|
17316
|
-
for (const e of l.terminal.slice().reverse()) {
|
|
17317
|
-
lines.push(`- ${e.dispatchId} · agent ${e.tier} · ${e.identity}${e.depth ? ` @${e.depth}` : ""} · ${e.state.toUpperCase()} · session ${e.sessionID || "(not started)"}`);
|
|
17318
|
-
const r = e.result;
|
|
17319
|
-
if (r !== undefined)
|
|
17320
|
-
lines.push(` result: ${JSON.stringify(r, null, 1).slice(0, 2000)}`);
|
|
17321
|
-
if (e.lateCompletion !== undefined)
|
|
17322
|
-
lines.push(` (killed; a late result arrived after the kill and was discarded: ${JSON.stringify(e.lateCompletion).slice(0, 300)})`);
|
|
17323
|
-
}
|
|
17324
|
-
}
|
|
17325
|
-
return { title: `dispatches: ${l.inFlight.length} in flight, ${l.terminal.length} terminal`, output: lines.join(`
|
|
17326
|
-
`) };
|
|
17327
|
-
}
|
|
17328
|
-
});
|
|
17329
|
-
var forgeDispatchKillTool = tool({
|
|
17330
|
-
description: "Cancel an in-flight background forge_dispatch by dispatchId: polling stops, the completion brief is suppressed, and the outcome is ledgered as killed. Refuses honestly if the dispatch already finished (use forge_dispatch_list). The child session itself cannot be aborted on this host version and is left for the host to reclaim.",
|
|
17331
|
-
args: {
|
|
17332
|
-
dispatchId: tool.schema.string().describe("The dispatchId returned by forge_dispatch (background) or shown by forge_dispatch_list")
|
|
17333
|
-
},
|
|
17334
|
-
execute: async (args) => {
|
|
17335
|
-
const engine = activeDispatchEngine;
|
|
17336
|
-
if (!engine)
|
|
17337
|
-
throw new Error("[forge] dispatch engine unavailable.");
|
|
17338
|
-
try {
|
|
17339
|
-
engine.kill(args.dispatchId);
|
|
17340
|
-
} catch (err) {
|
|
17341
|
-
if (err instanceof DispatchError)
|
|
17342
|
-
throw new Error(`[forge:dispatch:${err.code}] ${err.message}`);
|
|
17343
|
-
throw err;
|
|
17344
|
-
}
|
|
17345
|
-
return {
|
|
17346
|
-
title: `${args.dispatchId} killed`,
|
|
17347
|
-
output: `[forge:dispatch] ${args.dispatchId}: killed — polling stopped, the completion brief is suppressed, and the outcome is ledgered. The child session cannot be aborted on this host and is left for the host to reclaim.`
|
|
17348
|
-
};
|
|
17349
|
-
}
|
|
17350
|
-
});
|
|
17351
|
-
var CREW_COMMAND_TEMPLATE = [
|
|
17352
|
-
"Objective: {args}",
|
|
16223
|
+
var CREW_INIT_TEMPLATE = [
|
|
16224
|
+
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
17353
16225
|
"",
|
|
17354
|
-
"
|
|
16226
|
+
"CREW IS NOT INITIALIZED: no forge subagents are configured (no usable forge.json agents). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
|
|
17355
16227
|
"",
|
|
17356
|
-
"1.
|
|
17357
|
-
"
|
|
17358
|
-
"
|
|
17359
|
-
"
|
|
17360
|
-
"
|
|
17361
|
-
"
|
|
16228
|
+
"1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
|
|
16229
|
+
"{paths}",
|
|
16230
|
+
"",
|
|
16231
|
+
"2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead.",
|
|
16232
|
+
"",
|
|
16233
|
+
"Template (JSONC — comments allowed; `model` is the exact provider/model identity, `thoughtLevel` is optional: none/low/medium/high/max or a native level name):",
|
|
16234
|
+
"{template}",
|
|
16235
|
+
"",
|
|
16236
|
+
"After the file is saved: NEW agents require a host restart to appear in the task tool; thoughtLevel edits on existing agents apply without restart. Until then, crew stays unavailable."
|
|
17362
16237
|
].join(`
|
|
17363
16238
|
`);
|
|
16239
|
+
function crewOrchestrationTemplate(agentIds) {
|
|
16240
|
+
return [
|
|
16241
|
+
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
16242
|
+
"",
|
|
16243
|
+
`Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
|
|
16244
|
+
"",
|
|
16245
|
+
"You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
|
|
16246
|
+
"",
|
|
16247
|
+
"1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). If crew_begin refuses (a crew is already active here, or a plan draft is active), STOP and tell the user why — never start a second crew. Reconnaissance first if you need it to decompose well; a wrong plan is fixed by discarding and re-crewing, never by improvising outside the declared plan.",
|
|
16248
|
+
"2. WAVES, NEVER FLOODS: execute subtasks in waves of parallel native `task` calls (subagent_type = the matching forge-* agent; a wave's results return within the calling turn). Launch the next wave ONLY after the previous wave's results are in.",
|
|
16249
|
+
"3. MISSING ROLE: when a subtask needs a role no configured forge-* agent matches, run that subtask through a native task call anyway AND tell the user about the gap — suggest configuring the missing role in forge.json.",
|
|
16250
|
+
"4. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the task result text. A result that does not meet the statement is a failure even if the worker sounded confident.",
|
|
16251
|
+
"5. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or agent. If the retry also fails, record the subtask as FAIL with BOTH failure reports visible (attempts[]). Do not retry twice, do not paper over a failure.",
|
|
16252
|
+
"6. CLOSE: when every declared subtask has a verdict, call `crew_close` with the full report (title/verdict/evidence per subtask; attempts[] for FAILs). The gate cross-checks your report against the declared plan — a declared subtask missing from the report refuses the close, and so does an undeclared one. The user confirms the closing dialog."
|
|
16253
|
+
].join(`
|
|
16254
|
+
`);
|
|
16255
|
+
}
|
|
17364
16256
|
var crewBeginTool = tool({
|
|
17365
|
-
description: "Register this session's crew (from /crew). Refuses when a crew is already active in the session
|
|
16257
|
+
description: "Register this session's crew (from /crew) with its DECLARED subtask plan. Refuses when a crew is already active in the session, while a plan draft is active, or when subtasks are missing/duplicated. Crew state is in-memory: a host restart ends it honestly.",
|
|
17366
16258
|
args: {
|
|
17367
|
-
objective: tool.schema.string().describe("One-line crew objective (from /crew)")
|
|
16259
|
+
objective: tool.schema.string().describe("One-line crew objective (from /crew)"),
|
|
16260
|
+
subtasks: tool.schema.array(tool.schema.object({
|
|
16261
|
+
title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
|
|
16262
|
+
agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
|
|
16263
|
+
})).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
|
|
17368
16264
|
},
|
|
17369
16265
|
execute: async (args, context) => {
|
|
17370
16266
|
const state = stateForBan(context.sessionID);
|
|
@@ -17372,90 +16268,84 @@ var crewBeginTool = tool({
|
|
|
17372
16268
|
if (active && active.doc.status === "draft") {
|
|
17373
16269
|
throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17374
16270
|
}
|
|
16271
|
+
const loaded = forgeLoader?.load();
|
|
16272
|
+
if (!loaded || Object.keys(loaded.agents).length === 0) {
|
|
16273
|
+
const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16274
|
+
throw new Error([
|
|
16275
|
+
"[forge:crew] CREW IS NOT INITIALIZED: no forge subagents are configured. Crew orchestration is unavailable until the user sets it up.",
|
|
16276
|
+
`Create ONE of these files (project-level wins): ${paths.project} OR ${paths.global}`,
|
|
16277
|
+
'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
|
|
16278
|
+
"The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. Newly added agents need a host restart; thoughtLevel edits apply without one."
|
|
16279
|
+
].join(`
|
|
16280
|
+
`));
|
|
16281
|
+
}
|
|
17375
16282
|
if (!args.objective?.trim())
|
|
17376
16283
|
throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
|
|
16284
|
+
const raw = Array.isArray(args.subtasks) ? args.subtasks : [];
|
|
16285
|
+
if (raw.length === 0)
|
|
16286
|
+
throw new Error("[forge:crew] crew_begin requires subtasks[] — declare the plan before executing (decompose the objective into titled subtasks).");
|
|
16287
|
+
const seen = new Set;
|
|
16288
|
+
const subtasks = [];
|
|
16289
|
+
for (const s of raw) {
|
|
16290
|
+
const title = String(s?.title ?? "").trim();
|
|
16291
|
+
if (!title)
|
|
16292
|
+
throw new Error("[forge:crew] every subtask needs a non-empty title.");
|
|
16293
|
+
const key = title.toLowerCase();
|
|
16294
|
+
if (seen.has(key))
|
|
16295
|
+
throw new Error(`[forge:crew] duplicate subtask title "${title}" — titles must be unique (they are the crew_close matching key).`);
|
|
16296
|
+
seen.add(key);
|
|
16297
|
+
const agent = String(s?.agent ?? "").trim();
|
|
16298
|
+
subtasks.push(agent ? { title, agent } : { title });
|
|
16299
|
+
}
|
|
17377
16300
|
const existing = crews.get(context.sessionID);
|
|
17378
16301
|
if (existing) {
|
|
17379
16302
|
throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
|
|
17380
16303
|
}
|
|
17381
|
-
crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso() });
|
|
16304
|
+
crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
|
|
17382
16305
|
return {
|
|
17383
16306
|
title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
|
|
17384
|
-
output:
|
|
16307
|
+
output: [
|
|
16308
|
+
`[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
|
|
16309
|
+
`Declared plan (${subtasks.length}):`,
|
|
16310
|
+
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16311
|
+
"Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask."
|
|
16312
|
+
].join(`
|
|
16313
|
+
`)
|
|
17385
16314
|
};
|
|
17386
16315
|
}
|
|
17387
16316
|
});
|
|
17388
16317
|
var crewCloseTool = tool({
|
|
17389
|
-
description: "Close this session's crew with the final report (ask-level user confirmation). The gate cross-checks the report against the
|
|
16318
|
+
description: "Close this session's crew with the final report (ask-level user confirmation). The gate cross-checks the report against the declared subtask plan: every declared subtask needs a verdict (PASS/FAIL) and an evidence statement; an undeclared subtask in the report refuses the close; FAIL requires both failure reports (attempt + the one retry). On success the crew ends and the summary is the tool output (there is no persistent crew record).",
|
|
17390
16319
|
args: {
|
|
17391
16320
|
report: tool.schema.array(tool.schema.object({
|
|
17392
|
-
title: tool.schema.string().describe("Subtask title"),
|
|
16321
|
+
title: tool.schema.string().describe("Subtask title — must match a title declared at crew_begin"),
|
|
17393
16322
|
verdict: tool.schema.string().describe("PASS or FAIL"),
|
|
17394
|
-
evidence: tool.schema.string().describe("Acceptance evidence
|
|
16323
|
+
evidence: tool.schema.string().describe("Acceptance evidence: what the result contained that satisfies the acceptance statement"),
|
|
17395
16324
|
attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
|
|
17396
|
-
})).describe("The full crew report — one entry per subtask")
|
|
16325
|
+
})).describe("The full crew report — one entry per declared subtask")
|
|
17397
16326
|
},
|
|
17398
16327
|
execute: async (args, context) => {
|
|
17399
16328
|
const crew = crews.get(context.sessionID);
|
|
17400
16329
|
if (!crew)
|
|
17401
16330
|
throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
|
|
17402
|
-
const
|
|
17403
|
-
|
|
17404
|
-
const file2 = readFileSync9(join5(dispatchLogDir, "ledger.jsonl"), "utf8");
|
|
17405
|
-
for (const line of file2.split(`
|
|
17406
|
-
`)) {
|
|
17407
|
-
const t = line.trim();
|
|
17408
|
-
if (!t)
|
|
17409
|
-
continue;
|
|
17410
|
-
try {
|
|
17411
|
-
rows.push(JSON.parse(t));
|
|
17412
|
-
} catch {}
|
|
17413
|
-
}
|
|
17414
|
-
} catch {}
|
|
17415
|
-
const check2 = validateCrewReport(args.report ?? [], rows, { objective: crew.objective, startedAt: crew.startedAt, sessionID: context.sessionID });
|
|
16331
|
+
const report = args.report ?? [];
|
|
16332
|
+
const check2 = validateCrewReport(report, crew.subtasks);
|
|
17416
16333
|
if (!check2.ok) {
|
|
17417
|
-
throw new Error(`[forge:crew] crew_close refused — the report does not match the
|
|
16334
|
+
throw new Error(`[forge:crew] crew_close refused — the report does not match the declared plan:
|
|
17418
16335
|
${check2.gaps.map((g) => `- ${g}`).join(`
|
|
17419
16336
|
`)}
|
|
17420
16337
|
The crew stays active; complete or correct the report and call crew_close again.`);
|
|
17421
16338
|
}
|
|
17422
|
-
let tokensIn = 0;
|
|
17423
|
-
let tokensOut = 0;
|
|
17424
|
-
let costSum = 0;
|
|
17425
|
-
let costKnown = 0;
|
|
17426
|
-
for (const row of rows) {
|
|
17427
|
-
if (typeof row.dispatchId !== "string" || !check2.dispatchIds.includes(row.dispatchId))
|
|
17428
|
-
continue;
|
|
17429
|
-
const tok = row.tokens;
|
|
17430
|
-
tokensIn += tok?.input ?? 0;
|
|
17431
|
-
tokensOut += (tok?.output ?? 0) + (tok?.reasoning ?? 0);
|
|
17432
|
-
if (typeof row.costUsd === "number") {
|
|
17433
|
-
costSum += row.costUsd;
|
|
17434
|
-
costKnown++;
|
|
17435
|
-
}
|
|
17436
|
-
}
|
|
17437
16339
|
await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
|
|
17438
|
-
dispatchLedger.append({
|
|
17439
|
-
ts: nowIso(),
|
|
17440
|
-
event: "crew-summary",
|
|
17441
|
-
tier: "crew",
|
|
17442
|
-
outcome: "closed",
|
|
17443
|
-
sessionID: context.sessionID,
|
|
17444
|
-
note: crew.objective,
|
|
17445
|
-
tokens: { input: tokensIn, output: tokensOut, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
17446
|
-
costUsd: costKnown > 0 ? costSum : null,
|
|
17447
|
-
dispatchIds: check2.dispatchIds,
|
|
17448
|
-
subtasks: (args.report ?? []).map((r) => ({ title: r.title, verdict: String(r.verdict).toUpperCase(), evidence: r.evidence }))
|
|
17449
|
-
});
|
|
17450
16340
|
crews.delete(context.sessionID);
|
|
17451
|
-
const fails =
|
|
16341
|
+
const fails = report.filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
|
|
17452
16342
|
return {
|
|
17453
16343
|
title: `crew closed: ${crew.objective.slice(0, 60)}`,
|
|
17454
16344
|
output: [
|
|
17455
16345
|
`[forge:crew] Crew closed: ${crew.objective}`,
|
|
17456
|
-
`subtasks: ${
|
|
17457
|
-
|
|
17458
|
-
"
|
|
16346
|
+
`subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
|
|
16347
|
+
...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`),
|
|
16348
|
+
"This output is the crew's record (crew state is in-memory; nothing persists)."
|
|
17459
16349
|
].join(`
|
|
17460
16350
|
`)
|
|
17461
16351
|
};
|
|
@@ -17479,11 +16369,7 @@ function forgeTools() {
|
|
|
17479
16369
|
tools.forge_shell = forgeShellTool;
|
|
17480
16370
|
tools.forge_jobs = forgeJobsTool;
|
|
17481
16371
|
}
|
|
17482
|
-
if (!
|
|
17483
|
-
tools.forge_dispatch = forgeDispatchTool;
|
|
17484
|
-
tools.forge_dispatch_config = forgeDispatchConfigTool;
|
|
17485
|
-
tools.forge_dispatch_list = forgeDispatchListTool;
|
|
17486
|
-
tools.forge_dispatch_kill = forgeDispatchKillTool;
|
|
16372
|
+
if (!forgeDisabled) {
|
|
17487
16373
|
tools.crew_begin = crewBeginTool;
|
|
17488
16374
|
tools.crew_close = crewCloseTool;
|
|
17489
16375
|
}
|
|
@@ -17556,7 +16442,7 @@ function wrapupBriefText(goal, reason) {
|
|
|
17556
16442
|
}
|
|
17557
16443
|
async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
17558
16444
|
goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
|
|
17559
|
-
atomicWrite(goal.path, transitionGoal(
|
|
16445
|
+
atomicWrite(goal.path, transitionGoal(readFileSync8(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
|
|
17560
16446
|
engineForgetSession(state.sessionID);
|
|
17561
16447
|
if (wrapup && goal.doc.session) {
|
|
17562
16448
|
try {
|
|
@@ -17605,9 +16491,9 @@ async function continueIfEligible(client, sessionID) {
|
|
|
17605
16491
|
const ledgerPath = state.goalPath;
|
|
17606
16492
|
if (ledgerPath && existsSync3(ledgerPath)) {
|
|
17607
16493
|
try {
|
|
17608
|
-
const fresh = parseGoalLoose(
|
|
16494
|
+
const fresh = parseGoalLoose(readFileSync8(ledgerPath, "utf8"));
|
|
17609
16495
|
if (fresh && fresh.turnsUsed > 0) {
|
|
17610
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
16496
|
+
atomicWrite(ledgerPath, appendLedger(readFileSync8(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
|
|
17611
16497
|
}
|
|
17612
16498
|
} catch (err) {
|
|
17613
16499
|
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
@@ -17660,10 +16546,7 @@ async function continueIfEligible(client, sessionID) {
|
|
|
17660
16546
|
}
|
|
17661
16547
|
}
|
|
17662
16548
|
try {
|
|
17663
|
-
const
|
|
17664
|
-
const briefText = wakeEntries.length > 0 ? `${dispatchBriefText(wakeEntries)}
|
|
17665
|
-
|
|
17666
|
-
${goalBriefText(state, goal)}` : goalBriefText(state, goal);
|
|
16549
|
+
const briefText = goalBriefText(state, goal);
|
|
17667
16550
|
await client.session.prompt({
|
|
17668
16551
|
path: { id: sessionID },
|
|
17669
16552
|
body: { parts: [{ type: "text", text: briefText }] }
|
|
@@ -17672,7 +16555,7 @@ ${goalBriefText(state, goal)}` : goalBriefText(state, goal);
|
|
|
17672
16555
|
pendingContinuationTurn.add(sessionID);
|
|
17673
16556
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
17674
16557
|
state.goalPath = goal.path;
|
|
17675
|
-
atomicWrite(goal.path, incTurns(
|
|
16558
|
+
atomicWrite(goal.path, incTurns(readFileSync8(goal.path, "utf8"), nowIso()));
|
|
17676
16559
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
17677
16560
|
} catch (err) {
|
|
17678
16561
|
const n = (transportFails.get(sessionID) ?? 0) + 1;
|
|
@@ -17703,7 +16586,6 @@ var server = async (input, options) => {
|
|
|
17703
16586
|
hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
|
|
17704
16587
|
const client = input.client;
|
|
17705
16588
|
jobClient = input.client;
|
|
17706
|
-
dispatchClient = input.client;
|
|
17707
16589
|
const jobsOpts = options?.jobs;
|
|
17708
16590
|
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
17709
16591
|
jobsMode = jobsOpts.mode;
|
|
@@ -17721,18 +16603,6 @@ var server = async (input, options) => {
|
|
|
17721
16603
|
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
17722
16604
|
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
17723
16605
|
}
|
|
17724
|
-
const dpOpts = options?.dispatch;
|
|
17725
|
-
dispatchDisabled = dpOpts?.disable === true;
|
|
17726
|
-
if (Number.isInteger(dpOpts?.maxConcurrent) && dpOpts.maxConcurrent >= 1 && dpOpts.maxConcurrent <= 16) {
|
|
17727
|
-
dispatchMaxConcurrent = dpOpts.maxConcurrent;
|
|
17728
|
-
} else if (dpOpts?.maxConcurrent !== undefined) {
|
|
17729
|
-
dispatchMaxConcurrent = 4;
|
|
17730
|
-
}
|
|
17731
|
-
if (Number.isInteger(dpOpts?.timeoutMs) && dpOpts.timeoutMs >= 1000 && dpOpts.timeoutMs <= 600000) {
|
|
17732
|
-
dispatchTimeoutMs = dpOpts.timeoutMs;
|
|
17733
|
-
} else if (dpOpts?.timeoutMs !== undefined) {
|
|
17734
|
-
dispatchTimeoutMs = 600000;
|
|
17735
|
-
}
|
|
17736
16606
|
const watchdogLedger = createFileLedger(join5(watchdogLogDir(), "log.jsonl"));
|
|
17737
16607
|
const rawLocator = createLocator();
|
|
17738
16608
|
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
@@ -17788,7 +16658,6 @@ var server = async (input, options) => {
|
|
|
17788
16658
|
exitCleanup?.trigger("dispose");
|
|
17789
16659
|
jobManager.disposeAll();
|
|
17790
16660
|
watchdog.dispose();
|
|
17791
|
-
activeDispatchEngine?.dispose();
|
|
17792
16661
|
sessions.clear();
|
|
17793
16662
|
},
|
|
17794
16663
|
config: async (cfg) => {
|
|
@@ -17822,10 +16691,6 @@ var server = async (input, options) => {
|
|
|
17822
16691
|
template: PLAN_COMMAND_TEMPLATE,
|
|
17823
16692
|
description: "forge plan harness: no argument lists in-progress plans; resume continues the latest; discard abandons it; a goal enters planning discipline"
|
|
17824
16693
|
};
|
|
17825
|
-
cfg.command["crew"] ??= {
|
|
17826
|
-
template: CREW_COMMAND_TEMPLATE,
|
|
17827
|
-
description: "forge crew orchestration: decompose an objective into evidence-checked subtasks dispatched as paced background waves; at most one retry per subtask; crew_close cross-checks the report against the dispatch ledger"
|
|
17828
|
-
};
|
|
17829
16694
|
cfg.command["goal"] ??= {
|
|
17830
16695
|
template: GOAL_COMMAND_TEMPLATE,
|
|
17831
16696
|
description: "forge goal harness (autonomous, host-verified): no argument lists goals; pause/resume/discard drive the loop; add queues; next promotes; an objective drafts a contract and arms it (--check/--contains markers become verification items)"
|
|
@@ -17836,75 +16701,47 @@ var server = async (input, options) => {
|
|
|
17836
16701
|
if (permSection[gateKey] !== "deny")
|
|
17837
16702
|
permSection[gateKey] = "ask";
|
|
17838
16703
|
}
|
|
17839
|
-
if (!
|
|
17840
|
-
|
|
17841
|
-
|
|
17842
|
-
|
|
17843
|
-
if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
|
|
17844
|
-
forgeLoader = createForgeConfigLoader({
|
|
17845
|
-
projectDir: hostWorktree,
|
|
17846
|
-
homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
|
|
17847
|
-
});
|
|
17848
|
-
forgeLoaderDir = hostWorktree;
|
|
17849
|
-
}
|
|
17850
|
-
const loaded = forgeLoader.load();
|
|
17851
|
-
forgeConfigState = loaded;
|
|
17852
|
-
for (const finding of loaded.findings) {
|
|
17853
|
-
dispatchLedger.append({ ts: new Date().toISOString(), event: "validation", level: finding.level, code: finding.code, note: finding.message });
|
|
17854
|
-
}
|
|
17855
|
-
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
17856
|
-
const id = `forge-${agentId}`;
|
|
17857
|
-
if (agentSection[id] !== undefined)
|
|
17858
|
-
continue;
|
|
17859
|
-
agentSection[id] = forgeAgentDef(agentId, def);
|
|
17860
|
-
}
|
|
17861
|
-
const detected = configuredIdentitiesOf(cfg, snapshot.catalog);
|
|
17862
|
-
lastDetectedIdentities = detected;
|
|
17863
|
-
const built = buildDispatchConfig({
|
|
17864
|
-
configuredIdentities: detected,
|
|
17865
|
-
catalog: snapshot.catalog,
|
|
17866
|
-
userRoster: legacyInline ? dispatchOpts.roster ?? [] : [],
|
|
17867
|
-
userTiers: legacyInline ? dispatchOpts.tiers : undefined
|
|
16704
|
+
if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
|
|
16705
|
+
forgeLoader = createForgeConfigLoader({
|
|
16706
|
+
projectDir: hostWorktree,
|
|
16707
|
+
homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
|
|
17868
16708
|
});
|
|
17869
|
-
|
|
17870
|
-
|
|
17871
|
-
|
|
17872
|
-
|
|
17873
|
-
|
|
17874
|
-
|
|
17875
|
-
|
|
17876
|
-
|
|
17877
|
-
|
|
17878
|
-
|
|
17879
|
-
|
|
17880
|
-
|
|
17881
|
-
|
|
17882
|
-
|
|
16709
|
+
forgeLoaderDir = hostWorktree;
|
|
16710
|
+
}
|
|
16711
|
+
const loaded = forgeLoader.load();
|
|
16712
|
+
for (const finding of loaded.findings) {
|
|
16713
|
+
console.warn(`[forge:config] ${finding.level}: ${finding.message}`);
|
|
16714
|
+
}
|
|
16715
|
+
materializedSnapshot = {};
|
|
16716
|
+
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
16717
|
+
const id = `forge-${agentId}`;
|
|
16718
|
+
if (agentSection[id] !== undefined)
|
|
16719
|
+
continue;
|
|
16720
|
+
const entry = forgeAgentDef(agentId, def);
|
|
16721
|
+
agentSection[id] = entry;
|
|
16722
|
+
materializedSnapshot[id] = entry;
|
|
16723
|
+
}
|
|
16724
|
+
if (process.env.FORGE_TEST_NO_DISPATCH_FETCH === "1") {
|
|
16725
|
+
modelsDevCatalog = null;
|
|
16726
|
+
} else {
|
|
16727
|
+
try {
|
|
16728
|
+
const snapshot = await loadModelsDevSnapshot();
|
|
16729
|
+
modelsDevCatalog = snapshot.catalog;
|
|
16730
|
+
} catch {
|
|
16731
|
+
modelsDevCatalog = null;
|
|
17883
16732
|
}
|
|
17884
|
-
|
|
17885
|
-
|
|
17886
|
-
|
|
17887
|
-
|
|
17888
|
-
|
|
17889
|
-
|
|
17890
|
-
|
|
17891
|
-
|
|
17892
|
-
|
|
17893
|
-
|
|
17894
|
-
|
|
17895
|
-
|
|
17896
|
-
return roster.roster.some((e) => e.model === identity) || lastDetectedIdentities.includes(identity);
|
|
17897
|
-
},
|
|
17898
|
-
agents: () => forgeLoader?.load().agents ?? {},
|
|
17899
|
-
legacy: legacyInline,
|
|
17900
|
-
sink: (entry) => dispatchLedger.append(entry),
|
|
17901
|
-
registry: activeDispatchRegistry,
|
|
17902
|
-
onTerminal: (parentSessionID) => scheduleDispatchWake(parentSessionID, false),
|
|
17903
|
-
onDepth: queueDispatchDepth,
|
|
17904
|
-
providerFamily: dispatchProviderFamily,
|
|
17905
|
-
maxConcurrent: dispatchMaxConcurrent,
|
|
17906
|
-
timeoutMs: dispatchTimeoutMs
|
|
17907
|
-
});
|
|
16733
|
+
}
|
|
16734
|
+
cfg.command ??= {};
|
|
16735
|
+
const existingCrew = cfg.command["crew"];
|
|
16736
|
+
if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
|
|
16737
|
+
const agentIds = Object.keys(loaded.agents);
|
|
16738
|
+
const template = agentIds.length > 0 ? crewOrchestrationTemplate(agentIds) : CREW_INIT_TEMPLATE.replaceAll("{paths}", ` - ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).project}
|
|
16739
|
+
- ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ agents: { research: { model: "provider/model", thoughtLevel: "low" } } }, null, 2).split(`
|
|
16740
|
+
`).map((l) => " " + l).join(`
|
|
16741
|
+
`));
|
|
16742
|
+
const description = agentIds.length > 0 ? "forge crew orchestration: register a declared subtask plan, execute it in waves of parallel native task calls, close with an evidence-checked report" : "forge crew orchestration — NOT INITIALIZED: no forge subagents configured; invoking it shows the one-time setup guidance";
|
|
16743
|
+
cfg.command["crew"] = { template, description };
|
|
16744
|
+
crewCommandSnapshot = { template, description };
|
|
17908
16745
|
}
|
|
17909
16746
|
},
|
|
17910
16747
|
get tool() {
|
|
@@ -17935,32 +16772,52 @@ var server = async (input, options) => {
|
|
|
17935
16772
|
throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Present the plan summary and call plan_approve for user approval, or /plan discard to abandon it.`);
|
|
17936
16773
|
}
|
|
17937
16774
|
}
|
|
17938
|
-
if (input2.tool === "forge_dispatch") {
|
|
17939
|
-
const state = stateForBan(input2.sessionID);
|
|
17940
|
-
const active = state ? resolveActivePlan(state) : null;
|
|
17941
|
-
if (active && active.doc.status === "draft") {
|
|
17942
|
-
throw new Error("[forge] A plan is in draft; dispatching workers is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
|
|
17943
|
-
}
|
|
17944
|
-
}
|
|
17945
16775
|
},
|
|
17946
16776
|
"chat.params": async (input2, output) => {
|
|
17947
|
-
|
|
17948
|
-
|
|
16777
|
+
if (forgeDisabled)
|
|
16778
|
+
return;
|
|
16779
|
+
const raw = input2;
|
|
16780
|
+
const sessionID = raw.sessionID;
|
|
16781
|
+
if (!sessionID)
|
|
16782
|
+
return;
|
|
16783
|
+
const agentName = typeof raw.agent === "string" ? raw.agent : raw.agent?.name;
|
|
16784
|
+
if (typeof agentName !== "string" || !agentName.startsWith("forge-"))
|
|
16785
|
+
return;
|
|
16786
|
+
const frozen = sessionDepths.get(sessionID);
|
|
16787
|
+
if (frozen) {
|
|
16788
|
+
applyDepthTranslation(output.options ?? (output.options = {}), frozen.translation);
|
|
16789
|
+
return;
|
|
16790
|
+
}
|
|
16791
|
+
const agentId = agentName.slice("forge-".length);
|
|
16792
|
+
const def = forgeLoader?.load().agents[agentId];
|
|
16793
|
+
if (!def?.thoughtLevel)
|
|
16794
|
+
return;
|
|
16795
|
+
const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
|
|
16796
|
+
const modelID = typeof raw.model?.modelID === "string" ? raw.model.modelID : "";
|
|
16797
|
+
const family = dispatchProviderFamily(providerID);
|
|
16798
|
+
if (!family)
|
|
17949
16799
|
return;
|
|
17950
|
-
|
|
16800
|
+
const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, family, nativeLadder(modelsDevCatalog, `${providerID}/${modelID}`));
|
|
16801
|
+
if (!translation) {
|
|
16802
|
+
if (findingMessage && !depthFindingNotes.has(agentId)) {
|
|
16803
|
+
depthFindingNotes.set(agentId, findingMessage);
|
|
16804
|
+
console.warn(`[forge:depth] agent forge-${agentId}: ${findingMessage}`);
|
|
16805
|
+
}
|
|
16806
|
+
return;
|
|
16807
|
+
}
|
|
16808
|
+
sessionDepths.set(sessionID, { translation });
|
|
16809
|
+
applyDepthTranslation(output.options ?? (output.options = {}), translation);
|
|
17951
16810
|
},
|
|
17952
|
-
|
|
17953
|
-
queueDepth: queueDispatchDepth,
|
|
17954
|
-
depthState: (sessionID) => dispatchDepths.get(sessionID) ?? null,
|
|
17955
|
-
composePrompt: composeWorkerPrompt,
|
|
17956
|
-
registry: () => activeDispatchRegistry,
|
|
16811
|
+
__forgeSubagentsTest: {
|
|
17957
16812
|
crews: () => crews,
|
|
17958
|
-
|
|
17959
|
-
|
|
17960
|
-
|
|
17961
|
-
|
|
17962
|
-
|
|
17963
|
-
}
|
|
16813
|
+
depthState: (sessionID) => sessionDepths.get(sessionID)?.translation ?? null,
|
|
16814
|
+
depthFinding: (agentId) => depthFindingNotes.get(agentId) ?? null,
|
|
16815
|
+
catalog: () => modelsDevCatalog,
|
|
16816
|
+
setCatalog: (c) => {
|
|
16817
|
+
modelsDevCatalog = c;
|
|
16818
|
+
},
|
|
16819
|
+
crewCommand: () => crewCommandSnapshot,
|
|
16820
|
+
materialized: () => materializedSnapshot
|
|
17964
16821
|
},
|
|
17965
16822
|
"permission.ask": async (input2, output) => {
|
|
17966
16823
|
const meta = input2.metadata ?? {};
|
|
@@ -17999,13 +16856,13 @@ var server = async (input, options) => {
|
|
|
17999
16856
|
goalProbe(`idle event session=${sessionID}`);
|
|
18000
16857
|
scheduleIdleContinuation(client, sessionID);
|
|
18001
16858
|
deliverJobWakes(sessionID);
|
|
18002
|
-
scheduleDispatchWake(sessionID, true);
|
|
18003
16859
|
}
|
|
18004
16860
|
}
|
|
18005
16861
|
if (event.type === "session.deleted") {
|
|
18006
16862
|
const info = event.properties.info;
|
|
18007
16863
|
if (typeof info?.id === "string" && info.id) {
|
|
18008
16864
|
jobManager.onSessionEnd(info.id);
|
|
16865
|
+
sessionDepths.delete(info.id);
|
|
18009
16866
|
}
|
|
18010
16867
|
}
|
|
18011
16868
|
},
|
|
@@ -18121,13 +16978,30 @@ async function v2Setup(ctx) {
|
|
|
18121
16978
|
await ctx.agent.transform(async (draft) => {
|
|
18122
16979
|
if (typeof draft.get !== "function" || typeof draft.update !== "function")
|
|
18123
16980
|
return;
|
|
18124
|
-
if (draft.get(FORGE_AGENT)
|
|
18125
|
-
|
|
18126
|
-
|
|
18127
|
-
|
|
18128
|
-
|
|
18129
|
-
|
|
18130
|
-
}
|
|
16981
|
+
if (draft.get(FORGE_AGENT) === undefined) {
|
|
16982
|
+
draft.update(FORGE_AGENT, (agent) => {
|
|
16983
|
+
agent.description = "forge — the single general-purpose coding agent: takes implementation tasks directly; planning goes through /plan into the plan harness (plans land in .opencode/plan/, with approve/close confirmation gates and tick discipline enforced by tools and the permission layer).";
|
|
16984
|
+
agent.system = FORGE_PROMPT;
|
|
16985
|
+
agent.mode = "primary";
|
|
16986
|
+
});
|
|
16987
|
+
}
|
|
16988
|
+
try {
|
|
16989
|
+
const loader = createForgeConfigLoader({ projectDir: process.cwd(), homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16990
|
+
const loaded = loader.load();
|
|
16991
|
+
for (const [agentId, def] of Object.entries(loaded.agents)) {
|
|
16992
|
+
const id = `forge-${agentId}`;
|
|
16993
|
+
if (draft.get(id) !== undefined)
|
|
16994
|
+
continue;
|
|
16995
|
+
const v1def = forgeAgentDef(agentId, def);
|
|
16996
|
+
draft.update(id, (agent) => {
|
|
16997
|
+
agent.description = v1def.description;
|
|
16998
|
+
agent.system = v1def.prompt;
|
|
16999
|
+
agent.mode = v1def.mode;
|
|
17000
|
+
agent.model = v1def.model;
|
|
17001
|
+
agent.permission = v1def.permission;
|
|
17002
|
+
});
|
|
17003
|
+
}
|
|
17004
|
+
} catch {}
|
|
18131
17005
|
});
|
|
18132
17006
|
}
|
|
18133
17007
|
}
|