@bojackduy/opencode-loopd 1.10.5 → 1.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/README.md +24 -1
- package/dist/server.js +567 -11
- package/dist/tui.js +772 -590
- package/package.json +1 -1
- package/skills/loopd/SKILL.md +40 -1
package/dist/server.js
CHANGED
|
@@ -1545,6 +1545,64 @@ function createControlWorker(options) {
|
|
|
1545
1545
|
};
|
|
1546
1546
|
break;
|
|
1547
1547
|
}
|
|
1548
|
+
case "switch_goal_model": {
|
|
1549
|
+
if (!request.goalID) {
|
|
1550
|
+
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
const args = request.args;
|
|
1554
|
+
const model = args?.model;
|
|
1555
|
+
if (typeof model !== "string" || model.trim().length === 0) {
|
|
1556
|
+
response = { ...base, ok: false, message: "model (providerID/modelID) is required", errorCode: "bad_request" };
|
|
1557
|
+
break;
|
|
1558
|
+
}
|
|
1559
|
+
const ownerForSwitch = (await readState(directory)).goals.find((g) => g.id === request.goalID)?.ownerSessionID ?? "";
|
|
1560
|
+
const result = await goalSvc.switchModel(directory, request.goalID, ownerForSwitch, model.trim(), { resume: args?.resume === true });
|
|
1561
|
+
const state = await readState(directory);
|
|
1562
|
+
const outcomeMessage = {
|
|
1563
|
+
applied: `Model switched to ${result.model} on the same worker/session.`,
|
|
1564
|
+
deferred: `Worker busy \u2014 switch to ${result.model} queued; applies on the next turn.`,
|
|
1565
|
+
unsupported: `Model switch unsupported on this host (see loopd_list_models). Goal/session retained.`
|
|
1566
|
+
};
|
|
1567
|
+
response = {
|
|
1568
|
+
...base,
|
|
1569
|
+
ok: result.outcome !== "unsupported",
|
|
1570
|
+
message: outcomeMessage[result.outcome] ?? `Model switch ${result.outcome}.`,
|
|
1571
|
+
stateRevision: state.revision
|
|
1572
|
+
};
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
case "list_models": {
|
|
1576
|
+
const catalog = await goalSvc.listModels();
|
|
1577
|
+
const state = await readState(directory);
|
|
1578
|
+
response = {
|
|
1579
|
+
...base,
|
|
1580
|
+
ok: catalog.capability !== "unavailable",
|
|
1581
|
+
message: `Models: ${catalog.models.length} across ${catalog.providers.length} provider(s) [${catalog.capability}/${catalog.switching}]. Quota: ${catalog.quota.status} \u2014 ${catalog.quota.limitation}`,
|
|
1582
|
+
stateRevision: state.revision
|
|
1583
|
+
};
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
case "set_interactive": {
|
|
1587
|
+
if (!request.goalID) {
|
|
1588
|
+
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
const interactive = request.args?.interactive;
|
|
1592
|
+
if (typeof interactive !== "boolean") {
|
|
1593
|
+
response = { ...base, ok: false, message: "interactive (boolean) is required", errorCode: "bad_request" };
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
1596
|
+
const result = await goalSvc.setInteractive(directory, request.goalID, interactive);
|
|
1597
|
+
const state = await readState(directory);
|
|
1598
|
+
response = {
|
|
1599
|
+
...base,
|
|
1600
|
+
ok: result.ok,
|
|
1601
|
+
message: result.message,
|
|
1602
|
+
stateRevision: state.revision
|
|
1603
|
+
};
|
|
1604
|
+
break;
|
|
1605
|
+
}
|
|
1548
1606
|
case "send": {
|
|
1549
1607
|
const args = request.args;
|
|
1550
1608
|
const text = String(args.message || "").trim();
|
|
@@ -1957,6 +2015,8 @@ function createLoopEngine(options) {
|
|
|
1957
2015
|
knownWorkerSessions.add(goal.workerSessionID);
|
|
1958
2016
|
if (goal.status !== "active")
|
|
1959
2017
|
return false;
|
|
2018
|
+
if (goal.interactive === true && type === "session.idle")
|
|
2019
|
+
return false;
|
|
1960
2020
|
switch (type) {
|
|
1961
2021
|
case "session.idle":
|
|
1962
2022
|
return await handleSessionIdle(state, goal);
|
|
@@ -2345,6 +2405,7 @@ function createLoopEngine(options) {
|
|
|
2345
2405
|
revision: newState.revision
|
|
2346
2406
|
});
|
|
2347
2407
|
const updatedGoal = newState.goals.find((g) => g.id === goal.id);
|
|
2408
|
+
await goalService.observeProviderError(directory, goal.id, error, "session-error", goal.config.model);
|
|
2348
2409
|
if (updatedGoal?.status === "blocked") {
|
|
2349
2410
|
await appendEvent(directory, {
|
|
2350
2411
|
version: 1,
|
|
@@ -2453,7 +2514,7 @@ function createLoopEngine(options) {
|
|
|
2453
2514
|
revision: state.revision
|
|
2454
2515
|
});
|
|
2455
2516
|
try {
|
|
2456
|
-
await
|
|
2517
|
+
await goalService.compact(directory, goal.id);
|
|
2457
2518
|
} catch {}
|
|
2458
2519
|
await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
|
|
2459
2520
|
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -2568,7 +2629,8 @@ function createLoopEngine(options) {
|
|
|
2568
2629
|
if (stopped)
|
|
2569
2630
|
continue;
|
|
2570
2631
|
}
|
|
2571
|
-
|
|
2632
|
+
const manual = goal.interactive === true;
|
|
2633
|
+
if (!manual && runtime.phase === "waiting_retry" && runtime.retryAfter) {
|
|
2572
2634
|
if (Date.now() >= Date.parse(runtime.retryAfter)) {
|
|
2573
2635
|
await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
|
|
2574
2636
|
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -2581,7 +2643,7 @@ function createLoopEngine(options) {
|
|
|
2581
2643
|
goalService.continueTurn(directory, goal.id).catch(() => {});
|
|
2582
2644
|
}
|
|
2583
2645
|
}
|
|
2584
|
-
if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
|
|
2646
|
+
if (!manual && (runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
|
|
2585
2647
|
if (runtime.phase === "idle" && runtime.activeRunID) {
|
|
2586
2648
|
await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
|
|
2587
2649
|
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
@@ -2886,6 +2948,92 @@ var nativeRpcDefinition = {
|
|
|
2886
2948
|
}
|
|
2887
2949
|
};
|
|
2888
2950
|
|
|
2951
|
+
// src/server/model-catalog.ts
|
|
2952
|
+
function emptyCatalog(source, switching, capability = "unsupported") {
|
|
2953
|
+
const observedAt = new Date().toISOString();
|
|
2954
|
+
return {
|
|
2955
|
+
capability,
|
|
2956
|
+
source,
|
|
2957
|
+
observedAt,
|
|
2958
|
+
switching,
|
|
2959
|
+
providers: [],
|
|
2960
|
+
models: [],
|
|
2961
|
+
quota: {
|
|
2962
|
+
status: "unknown",
|
|
2963
|
+
capability: "unsupported",
|
|
2964
|
+
source,
|
|
2965
|
+
observedAt,
|
|
2966
|
+
limitation: "Host catalog does not report remaining quota or reset time. Connected/enabled does not prove remaining balance; local rate-limit observations are not balance data."
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
function record(value) {
|
|
2971
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
2972
|
+
}
|
|
2973
|
+
function model(raw, providerID, usable) {
|
|
2974
|
+
const value = record(raw);
|
|
2975
|
+
if (typeof value.id !== "string" || !value.id)
|
|
2976
|
+
return;
|
|
2977
|
+
const limit = record(value.limit);
|
|
2978
|
+
return {
|
|
2979
|
+
providerID,
|
|
2980
|
+
modelID: value.id,
|
|
2981
|
+
name: typeof value.name === "string" ? value.name : value.id,
|
|
2982
|
+
usable,
|
|
2983
|
+
...typeof limit.context === "number" && Number.isFinite(limit.context) ? { contextLimit: limit.context } : {},
|
|
2984
|
+
...typeof limit.output === "number" && Number.isFinite(limit.output) ? { outputLimit: limit.output } : {}
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
function normalizeV1Catalog(raw) {
|
|
2988
|
+
const result = emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
|
|
2989
|
+
const value = record(raw);
|
|
2990
|
+
if (!Array.isArray(value.all) || !Array.isArray(value.connected))
|
|
2991
|
+
return result;
|
|
2992
|
+
result.capability = "supported";
|
|
2993
|
+
const connected = new Set(value.connected.filter((id) => typeof id === "string"));
|
|
2994
|
+
for (const rawProvider of value.all) {
|
|
2995
|
+
const p = record(rawProvider);
|
|
2996
|
+
if (typeof p.id !== "string")
|
|
2997
|
+
continue;
|
|
2998
|
+
const available = connected.has(p.id);
|
|
2999
|
+
result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: available });
|
|
3000
|
+
for (const rawModel of Object.values(record(p.models))) {
|
|
3001
|
+
const m = model(rawModel, p.id, available);
|
|
3002
|
+
if (m)
|
|
3003
|
+
result.models.push(m);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
return result;
|
|
3007
|
+
}
|
|
3008
|
+
function normalizeV2Catalog(rawProviders, rawModels) {
|
|
3009
|
+
const result = emptyCatalog("v2 provider.list + model.list", "session", "unavailable");
|
|
3010
|
+
const providers = record(rawProviders).data;
|
|
3011
|
+
const models = record(rawModels).data;
|
|
3012
|
+
if (!Array.isArray(providers) || !Array.isArray(models))
|
|
3013
|
+
return result;
|
|
3014
|
+
result.capability = "supported";
|
|
3015
|
+
const disabled = new Set;
|
|
3016
|
+
for (const rawProvider of providers) {
|
|
3017
|
+
const p = record(rawProvider);
|
|
3018
|
+
if (typeof p.id !== "string")
|
|
3019
|
+
continue;
|
|
3020
|
+
if (p.activation === "disabled")
|
|
3021
|
+
disabled.add(p.id);
|
|
3022
|
+
result.providers.push({ providerID: p.id, name: typeof p.name === "string" ? p.name : p.id, connected: "unknown" });
|
|
3023
|
+
}
|
|
3024
|
+
const knownProviders = new Set(result.providers.map((p) => p.providerID));
|
|
3025
|
+
for (const rawModel of models) {
|
|
3026
|
+
const value = record(rawModel);
|
|
3027
|
+
if (typeof value.providerID !== "string" || !knownProviders.has(value.providerID))
|
|
3028
|
+
continue;
|
|
3029
|
+
const usable = disabled.has(value.providerID) || value.enabled === false ? false : value.enabled === true ? true : "unknown";
|
|
3030
|
+
const m = model(value, value.providerID, usable);
|
|
3031
|
+
if (m)
|
|
3032
|
+
result.models.push(m);
|
|
3033
|
+
}
|
|
3034
|
+
return result;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2889
3037
|
// src/server/host-adapter.ts
|
|
2890
3038
|
function parseModelRef(value) {
|
|
2891
3039
|
if (value === undefined)
|
|
@@ -2929,6 +3077,21 @@ function markParentNotified2(ownerSessionID, message) {
|
|
|
2929
3077
|
}
|
|
2930
3078
|
function createRealHost(client, directory) {
|
|
2931
3079
|
return {
|
|
3080
|
+
async listModels() {
|
|
3081
|
+
if (typeof client.provider?.list !== "function")
|
|
3082
|
+
return emptyCatalog("v1 provider.list", "next-prompt");
|
|
3083
|
+
try {
|
|
3084
|
+
const result = await withTimeout(client.provider.list({ query: { directory } }), 1e4, "OpenCode provider.list");
|
|
3085
|
+
if (result?.error)
|
|
3086
|
+
return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
|
|
3087
|
+
return normalizeV1Catalog(result?.data);
|
|
3088
|
+
} catch {
|
|
3089
|
+
return emptyCatalog("v1 provider.list", "next-prompt", "unavailable");
|
|
3090
|
+
}
|
|
3091
|
+
},
|
|
3092
|
+
async switchSessionModel() {
|
|
3093
|
+
return "next-prompt";
|
|
3094
|
+
},
|
|
2932
3095
|
async createWorker({ parentID, title, agent, model }) {
|
|
2933
3096
|
try {
|
|
2934
3097
|
const body = { parentID, title };
|
|
@@ -2971,7 +3134,7 @@ function createRealHost(client, directory) {
|
|
|
2971
3134
|
if (result?.error) {
|
|
2972
3135
|
const detail = describeError(result.error);
|
|
2973
3136
|
await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
|
|
2974
|
-
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}
|
|
3137
|
+
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`, { cause: result.error });
|
|
2975
3138
|
}
|
|
2976
3139
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2977
3140
|
return { messageID: result?.data?.messageID };
|
|
@@ -3064,9 +3227,12 @@ function createRealHost(client, directory) {
|
|
|
3064
3227
|
return [];
|
|
3065
3228
|
}
|
|
3066
3229
|
},
|
|
3067
|
-
async compactSession(sessionID) {
|
|
3230
|
+
async compactSession(sessionID, model) {
|
|
3068
3231
|
try {
|
|
3069
|
-
await
|
|
3232
|
+
const identity = model ?? (await this.readSession(sessionID))?.model;
|
|
3233
|
+
if (!identity || typeof client.session.summarize !== "function")
|
|
3234
|
+
return;
|
|
3235
|
+
await client.session.summarize({ path: { id: sessionID }, body: identity });
|
|
3070
3236
|
} catch {}
|
|
3071
3237
|
},
|
|
3072
3238
|
async notifyOwner(ownerSessionID, message, agent) {
|
|
@@ -3112,6 +3278,29 @@ function toWorkerCreation(result) {
|
|
|
3112
3278
|
function createV2Host(context, statuses, options = {}) {
|
|
3113
3279
|
const directory = context.location.directory;
|
|
3114
3280
|
return {
|
|
3281
|
+
async listModels() {
|
|
3282
|
+
if (typeof context.provider?.list !== "function" || typeof context.model?.list !== "function") {
|
|
3283
|
+
return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported");
|
|
3284
|
+
}
|
|
3285
|
+
try {
|
|
3286
|
+
const [providers, models] = await Promise.all([
|
|
3287
|
+
context.provider.list({ location: { directory } }),
|
|
3288
|
+
context.model.list({ location: { directory } })
|
|
3289
|
+
]);
|
|
3290
|
+
const catalog = normalizeV2Catalog(providers, models);
|
|
3291
|
+
if (typeof context.session.switchModel !== "function")
|
|
3292
|
+
catalog.switching = "unsupported";
|
|
3293
|
+
return catalog;
|
|
3294
|
+
} catch {
|
|
3295
|
+
return emptyCatalog("v2 provider.list + model.list", typeof context.session.switchModel === "function" ? "session" : "unsupported", "unavailable");
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
async switchSessionModel(sessionID, model) {
|
|
3299
|
+
if (typeof context.session.switchModel !== "function")
|
|
3300
|
+
return "unsupported";
|
|
3301
|
+
await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
|
|
3302
|
+
return "applied";
|
|
3303
|
+
},
|
|
3115
3304
|
async createWorker({ parentID, title, agent, model, goalID }) {
|
|
3116
3305
|
if (options.native) {
|
|
3117
3306
|
let outcome;
|
|
@@ -3266,8 +3455,13 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3266
3455
|
return [];
|
|
3267
3456
|
}
|
|
3268
3457
|
},
|
|
3269
|
-
async compactSession(sessionID) {
|
|
3458
|
+
async compactSession(sessionID, model) {
|
|
3270
3459
|
try {
|
|
3460
|
+
if (model) {
|
|
3461
|
+
if (typeof context.session.switchModel !== "function")
|
|
3462
|
+
return;
|
|
3463
|
+
await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
|
|
3464
|
+
}
|
|
3271
3465
|
await context.session.command({ sessionID, name: "compact", text: "" });
|
|
3272
3466
|
} catch {}
|
|
3273
3467
|
},
|
|
@@ -3298,6 +3492,40 @@ async function withTimeout(promise, timeoutMs, operation) {
|
|
|
3298
3492
|
}
|
|
3299
3493
|
}
|
|
3300
3494
|
|
|
3495
|
+
// src/domain/provider-limit.ts
|
|
3496
|
+
function object(value) {
|
|
3497
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
3498
|
+
}
|
|
3499
|
+
function observeProviderLimit(error, source, model) {
|
|
3500
|
+
const wrapper = object(error);
|
|
3501
|
+
const root = wrapper.cause ? object(wrapper.cause) : wrapper;
|
|
3502
|
+
const data = object(root.data);
|
|
3503
|
+
const status = root.statusCode ?? root.status ?? data.statusCode ?? data.status;
|
|
3504
|
+
const statusCode = typeof status === "number" ? status : undefined;
|
|
3505
|
+
const code = root.code ?? data.code;
|
|
3506
|
+
const message = [root.message, data.message, typeof error === "string" ? error : undefined].filter((s) => typeof s === "string").join(" ");
|
|
3507
|
+
if (statusCode === 401 || statusCode === 403 || /context.{0,20}(length|window|overflow)|maximum context|invalid.{0,10}(key|token)|unauthori[sz]ed|authentication|network|ECONN|ENOTFOUND|max.?turn|cost.?budget|budget.?limit/i.test(message))
|
|
3508
|
+
return;
|
|
3509
|
+
const quota = code === "insufficient_quota" || code === "quota_exceeded" || /\b(insufficient quota|quota (exceeded|exhausted)|exceeded.{0,20}quota|usage limit reached|credit balance.{0,20}(low|exhausted|insufficient))\b/i.test(message);
|
|
3510
|
+
const rate = statusCode === 429 || code === "rate_limit_exceeded" || /\b(rate[ -]limit(ed| exceeded| reached| hit)|too many requests)\b/i.test(message);
|
|
3511
|
+
if (!quota && !rate)
|
|
3512
|
+
return;
|
|
3513
|
+
const observedAt = new Date().toISOString();
|
|
3514
|
+
const headers = object(root.responseHeaders ?? data.responseHeaders);
|
|
3515
|
+
const retryAfter = root.retryAfter ?? data.retryAfter ?? headers["retry-after"];
|
|
3516
|
+
let retryAt;
|
|
3517
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) {
|
|
3518
|
+
retryAt = new Date(Date.now() + Math.min(retryAfter, 31536000) * 1000).toISOString();
|
|
3519
|
+
} else if (typeof retryAfter === "string") {
|
|
3520
|
+
if (/^\d+(\.\d+)?$/.test(retryAfter)) {
|
|
3521
|
+
retryAt = new Date(Date.now() + Math.min(Number(retryAfter), 31536000) * 1000).toISOString();
|
|
3522
|
+
} else if (Number.isFinite(Date.parse(retryAfter))) {
|
|
3523
|
+
retryAt = new Date(retryAfter).toISOString();
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
return { kind: quota ? "quota" : "rate-limit", source, observedAt, ...model ? { model } : {}, ...statusCode !== undefined ? { statusCode } : {}, ...retryAt ? { retryAt } : {} };
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3301
3529
|
// src/server/worker-session.ts
|
|
3302
3530
|
function createWorkerManager(host) {
|
|
3303
3531
|
return {
|
|
@@ -3481,6 +3709,217 @@ function createGoalService(host) {
|
|
|
3481
3709
|
throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active` + (ownedElsewhere ? ` (owned by session ${activeWriter.ownerSessionID}, not this session)` : "") + ". Pause, block, complete, or clear it before activating another workspace-writing goal." + (ownedElsewhere ? " Note: list_background_goals shows only this session's goals; clear/pause it from its owning session." : ""));
|
|
3482
3710
|
}
|
|
3483
3711
|
}
|
|
3712
|
+
async function listModels() {
|
|
3713
|
+
return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
|
|
3714
|
+
}
|
|
3715
|
+
async function validateModel(value) {
|
|
3716
|
+
const ref = parseModelRef(value);
|
|
3717
|
+
if (!ref)
|
|
3718
|
+
throw new Error("Model is required: providerID/modelID.");
|
|
3719
|
+
const model = `${ref.providerID}/${ref.modelID}`;
|
|
3720
|
+
const catalog = await listModels();
|
|
3721
|
+
if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel) {
|
|
3722
|
+
return { model, catalog };
|
|
3723
|
+
}
|
|
3724
|
+
if (!catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
|
|
3725
|
+
throw new Error("Model is unavailable or not positively known usable. Discover models with loopd_list_models first.");
|
|
3726
|
+
}
|
|
3727
|
+
return { model, catalog };
|
|
3728
|
+
}
|
|
3729
|
+
async function applyModel(directory, goal, model, reason) {
|
|
3730
|
+
if (!goal.workerSessionID || !host.switchSessionModel)
|
|
3731
|
+
return "unsupported";
|
|
3732
|
+
const previousModel = parseModelRef(goal.config.model) ?? (await host.readSession(goal.workerSessionID))?.model;
|
|
3733
|
+
const outcome = await host.switchSessionModel(goal.workerSessionID, parseModelRef(model));
|
|
3734
|
+
if (outcome === "unsupported")
|
|
3735
|
+
return outcome;
|
|
3736
|
+
try {
|
|
3737
|
+
await mutateState(directory, `goal.model-switch:${goal.id}`, async (s) => {
|
|
3738
|
+
const current = s.goals.find((g) => g.id === goal.id);
|
|
3739
|
+
if (!current)
|
|
3740
|
+
throw new Error("Goal removed during model switch.");
|
|
3741
|
+
current.config.model = model;
|
|
3742
|
+
current.modelSwitch = {
|
|
3743
|
+
...current.modelSwitch,
|
|
3744
|
+
pending: undefined,
|
|
3745
|
+
lastFailure: undefined,
|
|
3746
|
+
last: { from: goal.config.model, to: model, at: new Date().toISOString(), outcome, reason }
|
|
3747
|
+
};
|
|
3748
|
+
current.updatedAt = new Date().toISOString();
|
|
3749
|
+
return s;
|
|
3750
|
+
});
|
|
3751
|
+
} catch (error) {
|
|
3752
|
+
if (outcome === "applied") {
|
|
3753
|
+
if (!previousModel)
|
|
3754
|
+
throw new Error("Host accepted model switch but persistence failed; previous host model unknown, rollback unavailable.");
|
|
3755
|
+
await host.switchSessionModel(goal.workerSessionID, previousModel);
|
|
3756
|
+
}
|
|
3757
|
+
throw error;
|
|
3758
|
+
}
|
|
3759
|
+
await logServerEvent(directory, "goal.model-switched", { goalID: goal.id, workerSessionID: goal.workerSessionID, from: goal.config.model, to: model, reason, outcome });
|
|
3760
|
+
return outcome;
|
|
3761
|
+
}
|
|
3762
|
+
function switchModel(directory, goalID, ownerSessionID, value, opts) {
|
|
3763
|
+
return withGoalOperation(goalID, async () => {
|
|
3764
|
+
const state = await readState(directory);
|
|
3765
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
3766
|
+
if (!goal || !ownerSessionID || goal.ownerSessionID !== ownerSessionID)
|
|
3767
|
+
throw new Error("Goal not found or not owned by this session.");
|
|
3768
|
+
if (goal.status === "complete")
|
|
3769
|
+
throw new Error("Cannot switch a completed goal.");
|
|
3770
|
+
if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
|
|
3771
|
+
throw new Error("resume is only valid for a provider-limit-blocked goal.");
|
|
3772
|
+
}
|
|
3773
|
+
if (opts?.resume)
|
|
3774
|
+
assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
|
|
3775
|
+
const { model, catalog } = await validateModel(value);
|
|
3776
|
+
if (catalog.capability !== "supported" || catalog.switching === "unsupported" || !host.switchSessionModel || !goal.workerSessionID) {
|
|
3777
|
+
return { outcome: "unsupported", model };
|
|
3778
|
+
}
|
|
3779
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
3780
|
+
const status = await host.sessionStatus(goal.workerSessionID);
|
|
3781
|
+
if (status !== "idle" || runtime?.phase === "running" && leaseIsValid(runtime)) {
|
|
3782
|
+
await mutateState(directory, `goal.model-deferred:${goalID}`, async (s) => {
|
|
3783
|
+
const current = s.goals.find((g) => g.id === goalID);
|
|
3784
|
+
if (current) {
|
|
3785
|
+
current.modelSwitch = { ...current.modelSwitch, pending: { model, requestedAt: new Date().toISOString(), reason: "owner" } };
|
|
3786
|
+
current.updatedAt = new Date().toISOString();
|
|
3787
|
+
}
|
|
3788
|
+
return s;
|
|
3789
|
+
});
|
|
3790
|
+
return { outcome: "deferred", model };
|
|
3791
|
+
}
|
|
3792
|
+
const applied = await applyModel(directory, goal, model, "owner");
|
|
3793
|
+
if (opts?.resume && applied !== "unsupported") {
|
|
3794
|
+
await mutateState(directory, `goal.model-resume:${goalID}`, async (s) => {
|
|
3795
|
+
const g = s.goals.find((g) => g.id === goalID);
|
|
3796
|
+
if (g?.status === "blocked" && g.blocker?.kind === "provider-limit") {
|
|
3797
|
+
assertWorkspaceWriteAvailable(s, g, ownerSessionID);
|
|
3798
|
+
g.status = "active";
|
|
3799
|
+
g.blocker = undefined;
|
|
3800
|
+
g.updatedAt = new Date().toISOString();
|
|
3801
|
+
}
|
|
3802
|
+
return s;
|
|
3803
|
+
});
|
|
3804
|
+
await continueTurnUnlocked(directory, goalID);
|
|
3805
|
+
}
|
|
3806
|
+
return { outcome: applied === "next-prompt" ? "deferred" : applied, model, ...opts?.resume && applied !== "unsupported" ? { resumed: true } : {} };
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
async function applyPendingModel(directory, goal) {
|
|
3810
|
+
const pending = goal.modelSwitch?.pending;
|
|
3811
|
+
if (!pending)
|
|
3812
|
+
return;
|
|
3813
|
+
let reason = "catalog-unavailable";
|
|
3814
|
+
try {
|
|
3815
|
+
const { catalog } = await validateModel(pending.model);
|
|
3816
|
+
if (catalog.capability !== "supported" || catalog.switching === "unsupported")
|
|
3817
|
+
throw new Error("Pending model switch catalog unavailable.");
|
|
3818
|
+
reason = "host-rejected";
|
|
3819
|
+
const result = await applyModel(directory, goal, pending.model, pending.reason);
|
|
3820
|
+
if (result === "unsupported")
|
|
3821
|
+
throw new Error("Pending model switching unsupported by host.");
|
|
3822
|
+
} catch {
|
|
3823
|
+
await mutateState(directory, `goal.model-switch-failed:${goal.id}`, async (s) => {
|
|
3824
|
+
const g = s.goals.find((g) => g.id === goal.id);
|
|
3825
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
3826
|
+
if (g) {
|
|
3827
|
+
g.modelSwitch = { ...g.modelSwitch, pending: undefined, lastFailure: { at: new Date().toISOString(), reason } };
|
|
3828
|
+
if (g.status === "active") {
|
|
3829
|
+
g.status = "blocked";
|
|
3830
|
+
g.blocker = { reason: "Pending model switch was not accepted.", needed: "Discover available models, switch the same goal explicitly, then retry when safe.", at: new Date().toISOString() };
|
|
3831
|
+
}
|
|
3832
|
+
g.updatedAt = new Date().toISOString();
|
|
3833
|
+
}
|
|
3834
|
+
if (rt) {
|
|
3835
|
+
Object.assign(rt, releaseLease(rt));
|
|
3836
|
+
rt.retryAfter = undefined;
|
|
3837
|
+
}
|
|
3838
|
+
return s;
|
|
3839
|
+
});
|
|
3840
|
+
throw new Error("Pending model switch was not accepted; goal blocked without replacing its worker.");
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
async function observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel) {
|
|
3844
|
+
const state = await readState(directory);
|
|
3845
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
3846
|
+
if (!goal)
|
|
3847
|
+
return;
|
|
3848
|
+
const observation = observeProviderLimit(error, source, expectedModel ?? goal.config.model);
|
|
3849
|
+
if (!observation)
|
|
3850
|
+
return;
|
|
3851
|
+
const fallbackModels = goal.config.fallbackModels ?? [];
|
|
3852
|
+
const attempted = new Set(goal.modelFallback?.attempted ?? []);
|
|
3853
|
+
if (goal.config.model)
|
|
3854
|
+
attempted.add(goal.config.model);
|
|
3855
|
+
let pending;
|
|
3856
|
+
let status = "disabled";
|
|
3857
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
3858
|
+
const quotaBlocked = goal.status === "blocked" && runtime?.lastError === describeError(error);
|
|
3859
|
+
const sameModel = expectedModel === undefined || expectedModel === goal.config.model;
|
|
3860
|
+
const eligible = sameModel && (goal.status === "active" || quotaBlocked) && (!goal.modelSwitch?.pending || goal.modelSwitch.pending.reason === "quota");
|
|
3861
|
+
if (eligible && goal.modelSwitch?.pending?.reason === "quota") {
|
|
3862
|
+
pending = { ...goal.modelSwitch.pending, reason: "quota" };
|
|
3863
|
+
status = "prepared";
|
|
3864
|
+
} else if (eligible && fallbackModels.length) {
|
|
3865
|
+
const catalog = await listModels();
|
|
3866
|
+
if (catalog.capability === "unsupported" || catalog.switching === "unsupported" || !host.switchSessionModel)
|
|
3867
|
+
status = "unsupported";
|
|
3868
|
+
else if (catalog.capability !== "supported")
|
|
3869
|
+
status = "unavailable";
|
|
3870
|
+
else {
|
|
3871
|
+
status = "exhausted";
|
|
3872
|
+
for (const value of fallbackModels) {
|
|
3873
|
+
if (attempted.has(value))
|
|
3874
|
+
continue;
|
|
3875
|
+
attempted.add(value);
|
|
3876
|
+
const ref = parseModelRef(value);
|
|
3877
|
+
if (ref && catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
|
|
3878
|
+
pending = { model: value, requestedAt: new Date().toISOString(), reason: "quota" };
|
|
3879
|
+
status = "prepared";
|
|
3880
|
+
break;
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
await mutateState(directory, `goal.provider-limit:${goalID}`, async (s) => {
|
|
3886
|
+
const g = s.goals.find((g) => g.id === goalID);
|
|
3887
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
3888
|
+
if (!g)
|
|
3889
|
+
return s;
|
|
3890
|
+
g.lastProviderLimit = observation;
|
|
3891
|
+
if (sameModel && g.status === "blocked" && g.blocker && rt?.lastError === describeError(error))
|
|
3892
|
+
g.blocker.kind = "provider-limit";
|
|
3893
|
+
if (eligible) {
|
|
3894
|
+
g.modelFallback = { attempted: [...attempted], status };
|
|
3895
|
+
if (pending && (!g.modelSwitch?.pending || g.modelSwitch.pending.reason === "quota")) {
|
|
3896
|
+
g.modelSwitch = { ...g.modelSwitch, pending };
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
if (rt?.phase === "waiting_retry" && observation.retryAt && Date.parse(observation.retryAt) > Date.parse(rt.retryAfter ?? ""))
|
|
3900
|
+
rt.retryAfter = observation.retryAt;
|
|
3901
|
+
g.updatedAt = new Date().toISOString();
|
|
3902
|
+
return s;
|
|
3903
|
+
});
|
|
3904
|
+
await logServerEvent(directory, "goal.provider-limit", { goalID, model: goal.config.model, kind: observation.kind, source, fallbackStatus: status, to: pending?.model });
|
|
3905
|
+
}
|
|
3906
|
+
function observeProviderError(directory, goalID, error, source, expectedModel) {
|
|
3907
|
+
return withGoalOperation(goalID, () => observeProviderErrorUnlocked(directory, goalID, error, source, expectedModel));
|
|
3908
|
+
}
|
|
3909
|
+
function compact(directory, goalID) {
|
|
3910
|
+
return withGoalOperation(goalID, async () => {
|
|
3911
|
+
const state = await readState(directory);
|
|
3912
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
3913
|
+
if (!goal?.workerSessionID || goal.status !== "active")
|
|
3914
|
+
return;
|
|
3915
|
+
if (await host.sessionStatus(goal.workerSessionID) !== "idle")
|
|
3916
|
+
return;
|
|
3917
|
+
await applyPendingModel(directory, goal);
|
|
3918
|
+
const fresh = (await readState(directory)).goals.find((g) => g.id === goalID);
|
|
3919
|
+
if (fresh?.workerSessionID)
|
|
3920
|
+
await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3484
3923
|
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
3485
3924
|
const detail = describeError(error);
|
|
3486
3925
|
let runID = "unknown";
|
|
@@ -3539,6 +3978,7 @@ function createGoalService(host) {
|
|
|
3539
3978
|
revision: state.revision
|
|
3540
3979
|
});
|
|
3541
3980
|
}
|
|
3981
|
+
await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
|
|
3542
3982
|
}
|
|
3543
3983
|
async function ensureWorkerSession(directory, goal) {
|
|
3544
3984
|
let session = sessions.get(goal.id);
|
|
@@ -3573,6 +4013,20 @@ function createGoalService(host) {
|
|
|
3573
4013
|
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
3574
4014
|
}
|
|
3575
4015
|
async function startUnlocked(directory, input, id) {
|
|
4016
|
+
if (input.config?.fallbackModels?.length) {
|
|
4017
|
+
if (input.config.fallbackModels.length > 16)
|
|
4018
|
+
throw new Error("At most 16 ordered fallback models are allowed.");
|
|
4019
|
+
const catalog = await listModels();
|
|
4020
|
+
for (const value of input.config.fallbackModels) {
|
|
4021
|
+
const ref = parseModelRef(value);
|
|
4022
|
+
if (!ref)
|
|
4023
|
+
throw new Error("Fallback model must be providerID/modelID.");
|
|
4024
|
+
if (catalog.capability === "supported" && !catalog.models.some((m) => m.providerID === ref.providerID && m.modelID === ref.modelID && m.usable === true)) {
|
|
4025
|
+
throw new Error("Fallback model is unavailable or not positively known usable.");
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
input.config.fallbackModels = [...new Set(input.config.fallbackModels.map((m) => m.trim()))];
|
|
4029
|
+
}
|
|
3576
4030
|
let parentAgent = input.parentAgent;
|
|
3577
4031
|
let parentModel = input.parentModel;
|
|
3578
4032
|
if ((!parentAgent || !parentModel) && host.readSession) {
|
|
@@ -3598,6 +4052,8 @@ function createGoalService(host) {
|
|
|
3598
4052
|
});
|
|
3599
4053
|
if (typeof input.costBudget === "number")
|
|
3600
4054
|
goal.costBudget = input.costBudget;
|
|
4055
|
+
if (input.interactive === true)
|
|
4056
|
+
goal.interactive = true;
|
|
3601
4057
|
if (parentAgent)
|
|
3602
4058
|
goal.parentAgent = parentAgent;
|
|
3603
4059
|
if (parentModel)
|
|
@@ -3800,6 +4256,11 @@ function createGoalService(host) {
|
|
|
3800
4256
|
return;
|
|
3801
4257
|
if (!opts?.force && !await workers.isIdle(session.workerSessionID))
|
|
3802
4258
|
return;
|
|
4259
|
+
if (goal.modelSwitch?.pending) {
|
|
4260
|
+
if (await host.sessionStatus(session.workerSessionID) !== "idle")
|
|
4261
|
+
return;
|
|
4262
|
+
await applyPendingModel(directory, goal);
|
|
4263
|
+
}
|
|
3803
4264
|
let acquired = false;
|
|
3804
4265
|
const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
|
|
3805
4266
|
const g = s.goals.find((item) => item.id === goalID);
|
|
@@ -3902,6 +4363,38 @@ function createGoalService(host) {
|
|
|
3902
4363
|
throw error;
|
|
3903
4364
|
}
|
|
3904
4365
|
}
|
|
4366
|
+
async function setInteractiveUnlocked(directory, goalID, interactive) {
|
|
4367
|
+
const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
|
|
4368
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
4369
|
+
if (!g)
|
|
4370
|
+
return s;
|
|
4371
|
+
if (interactive)
|
|
4372
|
+
g.interactive = true;
|
|
4373
|
+
else
|
|
4374
|
+
delete g.interactive;
|
|
4375
|
+
g.updatedAt = new Date().toISOString();
|
|
4376
|
+
return s;
|
|
4377
|
+
});
|
|
4378
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
4379
|
+
if (!goal)
|
|
4380
|
+
return { ok: false, message: "Goal not found." };
|
|
4381
|
+
await appendEvent(directory, {
|
|
4382
|
+
version: 1,
|
|
4383
|
+
eventID: randomUUID5(),
|
|
4384
|
+
goalID,
|
|
4385
|
+
type: "goal.interactive_changed",
|
|
4386
|
+
interactive,
|
|
4387
|
+
timestamp: new Date().toISOString(),
|
|
4388
|
+
revision: state.revision
|
|
4389
|
+
}).catch(() => {});
|
|
4390
|
+
return {
|
|
4391
|
+
ok: true,
|
|
4392
|
+
message: interactive ? `Goal "${goal.name}" is now manual: the engine will not start turns \u2014 steer it with :send/nudge.` : `Goal "${goal.name}" is back on auto: the engine resumes idle recovery and retries.`
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
function setInteractive(directory, goalID, interactive) {
|
|
4396
|
+
return withGoalOperation(goalID, () => setInteractiveUnlocked(directory, goalID, interactive));
|
|
4397
|
+
}
|
|
3905
4398
|
async function pauseUnlocked(directory, goalID) {
|
|
3906
4399
|
const preState = await readState(directory);
|
|
3907
4400
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
@@ -4264,7 +4757,7 @@ function createGoalService(host) {
|
|
|
4264
4757
|
function accountUsage(directory, goalID) {
|
|
4265
4758
|
return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
|
|
4266
4759
|
}
|
|
4267
|
-
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage };
|
|
4760
|
+
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, observeProviderError, compact };
|
|
4268
4761
|
}
|
|
4269
4762
|
|
|
4270
4763
|
// src/application/schedule-worker.ts
|
|
@@ -6793,6 +7286,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6793
7286
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
6794
7287
|
agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Prefer omit \u2014 inherits the calling session's agent if omitted, else plugin defaultAgent. Only pass when explicitly requested.`),
|
|
6795
7288
|
model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Prefer omit \u2014 inherits the calling session's live model if omitted, else plugin defaultModel. Only pass when explicitly requested.`),
|
|
7289
|
+
fallbackModels: tool.schema.array(tool.schema.string()).optional().describe("Explicit ordered providerID/modelID alternatives for provider quota/rate-limit failures only. Default empty: no automatic paid/provider switch. Discover with loopd_list_models; same worker/session retained. Interactive goals never auto-wake."),
|
|
6796
7290
|
costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
|
|
6797
7291
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
6798
7292
|
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
@@ -6804,7 +7298,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6804
7298
|
compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
|
|
6805
7299
|
timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms."),
|
|
6806
7300
|
scheduleEveryMs: tool.schema.number().optional().describe("Interval in ms to auto-requeue the same goal after each completion. Minimum 1000. Enables repetitive dialogue reduction."),
|
|
6807
|
-
scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs.")
|
|
7301
|
+
scheduleMaxRuns: tool.schema.number().optional().describe("Maximum total runs including the initial run. Undefined = unlimited. Requires scheduleEveryMs."),
|
|
7302
|
+
interactive: tool.schema.boolean().optional().describe("Manual mode: the engine never starts turns on its own (no idle recovery, no retries). Every turn comes from explicit steering (:send, nudge, resume, retry). Use for interactive tasks where the worker stops and waits for the next input \u2014 the tick forcing a turn there wastes tokens and breaks the wait. Toggleable later via the dashboard :interactive command.")
|
|
6808
7303
|
},
|
|
6809
7304
|
execute: async (args, context) => {
|
|
6810
7305
|
const sessionID = context?.sessionID || hostSessionID;
|
|
@@ -6824,6 +7319,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6824
7319
|
config.agent = args.agent;
|
|
6825
7320
|
if (args.model)
|
|
6826
7321
|
config.model = args.model;
|
|
7322
|
+
if (args.fallbackModels)
|
|
7323
|
+
config.fallbackModels = args.fallbackModels;
|
|
6827
7324
|
if (args.checks)
|
|
6828
7325
|
config.checks = args.checks;
|
|
6829
7326
|
if (args.checkCwd)
|
|
@@ -6899,7 +7396,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6899
7396
|
config: resolution.config,
|
|
6900
7397
|
costBudget,
|
|
6901
7398
|
parentAgent: parentDefaults.parentAgent,
|
|
6902
|
-
parentModel: parentDefaults.parentModel
|
|
7399
|
+
parentModel: parentDefaults.parentModel,
|
|
7400
|
+
...args.interactive === true ? { interactive: true } : {}
|
|
6903
7401
|
});
|
|
6904
7402
|
return {
|
|
6905
7403
|
title: "Goal created",
|
|
@@ -6913,6 +7411,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
6913
7411
|
agent: resolution.config.agent,
|
|
6914
7412
|
model: resolution.config.model,
|
|
6915
7413
|
costBudget: goal.costBudget,
|
|
7414
|
+
interactive: goal.interactive === true,
|
|
6916
7415
|
checks: resolution.config.checks || [],
|
|
6917
7416
|
workspaceWrite: resolution.config.workspaceWrite,
|
|
6918
7417
|
defaultsApplied: resolution.defaultsApplied,
|
|
@@ -7457,6 +7956,53 @@ function withTimeout2(promise, ms) {
|
|
|
7457
7956
|
function ownerTools(options) {
|
|
7458
7957
|
const { directory, host, goalService } = options;
|
|
7459
7958
|
return {
|
|
7959
|
+
loopd_list_models: tool2({
|
|
7960
|
+
description: "Discover host providers/models usable by this agent and inspect quota limitations plus assigned identities of this session's existing goals. " + "Remaining quota is unknown unless a supported integration reports it; connected/enabled is not remaining balance. " + "Workflow: discover -> inspect_background_goal for last local quota error -> switch_goal_model on the SAME goal, never create a duplicate to change provider. No secrets/auth data are returned.",
|
|
7961
|
+
args: {},
|
|
7962
|
+
execute: async (_args, context) => {
|
|
7963
|
+
if (!context?.sessionID)
|
|
7964
|
+
return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
|
|
7965
|
+
const [catalog, state] = await Promise.all([goalService.listModels(), readState(directory)]);
|
|
7966
|
+
return {
|
|
7967
|
+
title: "Provider/model capabilities",
|
|
7968
|
+
output: JSON.stringify({
|
|
7969
|
+
ok: true,
|
|
7970
|
+
...catalog,
|
|
7971
|
+
assignedGoals: state.goals.filter((g) => g.ownerSessionID === context.sessionID).map((g) => ({
|
|
7972
|
+
id: g.id,
|
|
7973
|
+
model: g.config.model,
|
|
7974
|
+
workerSessionID: g.workerSessionID,
|
|
7975
|
+
modelSwitch: g.modelSwitch,
|
|
7976
|
+
modelFallback: g.modelFallback,
|
|
7977
|
+
fallbackModels: g.config.fallbackModels ?? [],
|
|
7978
|
+
lastProviderLimit: g.lastProviderLimit
|
|
7979
|
+
}))
|
|
7980
|
+
}, null, 2)
|
|
7981
|
+
};
|
|
7982
|
+
}
|
|
7983
|
+
}),
|
|
7984
|
+
switch_goal_model: tool2({
|
|
7985
|
+
description: "Switch provider/model for an EXISTING goal owned by this session, preserving goal ID, worker session, transcript, checks and budgets. " + "First discover usable models with loopd_list_models, then inspect_background_goal for quota/error information. " + "Busy workers defer to the next safe turn; v1 changes apply on the next prompt. No abort, duplicate worker, budget reset, unpause or interactive wake. " + "Returns applied/deferred/unsupported honestly; an unsupported catalog cannot validate a switch. resume=true explicitly resumes ONLY a provider-limit-blocked goal and requests its next turn (including interactive); never resets budgets.",
|
|
7986
|
+
args: {
|
|
7987
|
+
goal_id: tool2.schema.string().describe("Existing owned goal ID; never create a replacement goal to switch models."),
|
|
7988
|
+
model: tool2.schema.string().describe("Available providerID/modelID from loopd_list_models."),
|
|
7989
|
+
resume: tool2.schema.boolean().optional().describe("Explicitly resume only a quota/rate-limit-blocked goal after a safe switch. Default false. Paused/budget-limited/unrelated-blocked goals are not resumed.")
|
|
7990
|
+
},
|
|
7991
|
+
execute: async (args, context) => {
|
|
7992
|
+
if (!context?.sessionID)
|
|
7993
|
+
return { title: "No session", output: JSON.stringify({ ok: false, message: "No session context available." }) };
|
|
7994
|
+
const state = await readState(directory);
|
|
7995
|
+
if (!state.goals.some((g) => g.id === args.goal_id && g.ownerSessionID === context.sessionID)) {
|
|
7996
|
+
return { title: "Switch denied", output: JSON.stringify({ ok: false, message: "Goal not found or not owned by this session." }) };
|
|
7997
|
+
}
|
|
7998
|
+
try {
|
|
7999
|
+
const result = await goalService.switchModel(directory, args.goal_id, context.sessionID, args.model, { resume: args.resume });
|
|
8000
|
+
return { title: `Model switch: ${result.outcome}`, output: JSON.stringify({ ok: result.outcome !== "unsupported", ...result, goalID: args.goal_id, message: "Same goal and worker retained. Deferred requests require the next permitted turn; no automatic interactive wake." }) };
|
|
8001
|
+
} catch {
|
|
8002
|
+
return { title: "Model switch failed", output: JSON.stringify({ ok: false, message: "Switch rejected: verify ownership, model availability and host capability with loopd_list_models and inspect_background_goal. Existing goal/session retained." }) };
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
}),
|
|
7460
8006
|
list_background_goals: tool2({
|
|
7461
8007
|
description: "List all active background goals owned by this session. Shows contract (name, status, phase, turn, last progress, blocker) \u2014 only goals with your ownerSessionID appear.",
|
|
7462
8008
|
args: {},
|
|
@@ -7498,6 +8044,11 @@ function ownerTools(options) {
|
|
|
7498
8044
|
maxTurns: g.config.maxTurns,
|
|
7499
8045
|
agent: g.config.agent,
|
|
7500
8046
|
model: g.config.model,
|
|
8047
|
+
modelSwitch: g.modelSwitch,
|
|
8048
|
+
modelFallback: g.modelFallback,
|
|
8049
|
+
lastProviderLimit: g.lastProviderLimit,
|
|
8050
|
+
fallbackModels: g.config.fallbackModels ?? [],
|
|
8051
|
+
quotaLimitation: "Remaining provider quota unknown; connected/enabled is not balance data. See loopd_list_models.",
|
|
7501
8052
|
lastProgress: g.lastProgress?.summary?.slice(0, 120),
|
|
7502
8053
|
lastProgressAt: g.lastProgress?.at,
|
|
7503
8054
|
blocker: g.blocker?.reason?.slice(0, 120),
|
|
@@ -7594,11 +8145,16 @@ function ownerTools(options) {
|
|
|
7594
8145
|
workspaceWrite: goal.config.workspaceWrite,
|
|
7595
8146
|
agent: goal.config.agent,
|
|
7596
8147
|
model: goal.config.model,
|
|
8148
|
+
fallbackModels: goal.config.fallbackModels ?? [],
|
|
7597
8149
|
parentAgent: goal.parentAgent,
|
|
7598
8150
|
parentModel: goal.parentModel,
|
|
7599
8151
|
schedule: goal.config.schedule
|
|
7600
8152
|
},
|
|
7601
8153
|
lastProgress: goal.lastProgress,
|
|
8154
|
+
modelSwitch: goal.modelSwitch,
|
|
8155
|
+
modelFallback: goal.modelFallback,
|
|
8156
|
+
lastProviderLimit: goal.lastProviderLimit,
|
|
8157
|
+
quotaLimitation: "Remaining provider quota unknown; local quota errors are observations, not balance data. See loopd_list_models.",
|
|
7602
8158
|
completionEvidence: goal.completionEvidence,
|
|
7603
8159
|
blocker: goal.blocker,
|
|
7604
8160
|
tokensUsed: goal.tokensUsed,
|
|
@@ -8341,7 +8897,7 @@ function commandTools(options) {
|
|
|
8341
8897
|
// src/server/plugin.ts
|
|
8342
8898
|
init_state_repository();
|
|
8343
8899
|
// package.json
|
|
8344
|
-
var version = "1.
|
|
8900
|
+
var version = "1.11.2";
|
|
8345
8901
|
|
|
8346
8902
|
// src/server/plugin.ts
|
|
8347
8903
|
var PLUGIN_ID = "opencode-loopd.server";
|