feat(mini-apps): capable models for app bg-tasks

- list-models tool: returns allowed model IDs (gateway for signed-in, config
  for BYOK) + a safe defaultModel, so the copilot picks a valid capable model
  instead of guessing rejected IDs
- patch-background-task: clearModel option to reset a bad model/provider
  override (falls back to default); patchTask can now delete fields
- build-mini-app skill: use list-models + set a capable model on data tasks
This commit is contained in:
Gagan 2026-07-02 02:31:00 +05:30
parent 61f2bcff5a
commit b548173593
3 changed files with 42 additions and 8 deletions

View file

@ -112,10 +112,15 @@ buggy run can't corrupt the app with the wrong shape or wipe series with empties
The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\` The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\`
atomically (deterministic path + write the agent never touches files). Set the atomically (deterministic path + write the agent never touches files). Set the
manifest's \`agent\` field to the task's slug, and give the task a **capable model** manifest's \`agent\` field to the task's slug.
(e.g. a Claude Sonnet / GPT-class model) via its model override the default
light model fabricates output and hallucinates tool names on side-effect tasks. **Give the task a capable model.** The default bg-task model is a light one that
Give it a sensible trigger (cron/window) from the background-task skill. fabricates output and hallucinates tool names on data/side-effect tasks. Set the
task's \`model\` (via \`create-background-task\`) to a strong reasoning model —
but only to an **allowed ID**: call **\`list-models\`** first and pick from what it
returns (arbitrary IDs are rejected). \`list-models.defaultModel\` is always a safe
capable choice. Give the task a sensible trigger (cron/window) from the
background-task skill.
## 5. Finalize ## 5. Finalize
@ -142,7 +147,8 @@ do this once the app is installed AND its data is populated, so it renders ready
- **bg-task has no shell:** don't generate \`refresh.sh\` / \`executeCommand\` - **bg-task has no shell:** don't generate \`refresh.sh\` / \`executeCommand\`
steps for the data agent it can't run them headlessly. steps for the data agent it can't run them headlessly.
- **model:** set a capable model on any data/side-effect bg-task; the default is - **model:** set a capable model on any data/side-effect bg-task; the default is
too weak and will fabricate results. too weak and will fabricate results. Use \`list-models\` to get allowed IDs —
don't guess model IDs (unknown ones are rejected as "Model not allowed").
## Manifest schema (manifest.json) ## Manifest schema (manifest.json)

View file

@ -52,6 +52,7 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'), slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'), triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."), projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
clearModel: z.boolean().optional().describe("Reset the task's model/provider override so it falls back to the default. Use this to unstick a bad/rejected model value (do not also pass model)."),
}); });
// Turn a user-supplied directory into a registered code project id. Reuses the // Turn a user-supplied directory into a registered code project id. Reuses the
@ -93,6 +94,7 @@ import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai"; import { generateText } from "ai";
import { createProvider } from "../../models/models.js"; import { createProvider } from "../../models/models.js";
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js";
import { listGatewayModels } from "../../models/gateway.js";
import { captureLlmUsage } from "../../analytics/usage.js"; import { captureLlmUsage } from "../../analytics/usage.js";
import { getCurrentUseCase, withUseCase } from "../../analytics/use_case.js"; import { getCurrentUseCase, withUseCase } from "../../analytics/use_case.js";
import { isSignedIn } from "../../account/account.js"; import { isSignedIn } from "../../account/account.js";
@ -1596,6 +1598,25 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
} }
}, },
}, },
'list-models': {
description: "List model IDs available for model overrides (e.g. to set a capable model on a background task). Signed-in users get the Rowboat gateway's allowed models; BYOK users get their configured model. Call this BEFORE setting a bg-task `model` so you pick a valid, allowed ID (arbitrary IDs are rejected). Returns { defaultModel, models }.",
inputSchema: z.object({}),
execute: async () => {
try {
if (await isSignedIn()) {
const { providers } = await listGatewayModels();
const models = providers.flatMap((p) => p.models.map((m) => m.id));
const { model: defaultModel } = await getDefaultModelAndProvider();
return { signedIn: true, defaultModel, models };
}
const { model, provider } = await getDefaultModelAndProvider();
return { signedIn: false, defaultModel: model, provider, models: [model] };
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
},
isAvailable: async () => true,
},
'fetch-url': { 'fetch-url': {
description: "Fetch an HTTP(S) URL and return the response body as text. Use this to pull data from web APIs or pages (e.g. a JSON endpoint) — especially in background tasks, which have no shell. GET by default; supports POST with a body. Returns { ok, status, statusText, body } (body truncated if very large). For JSON, parse the returned body.", description: "Fetch an HTTP(S) URL and return the response body as text. Use this to pull data from web APIs or pages (e.g. a JSON endpoint) — especially in background tasks, which have no shell. GET by default; supports POST with a body. Returns { ok, status, statusText, body } (body truncated if very large). For JSON, parse the returned body.",
inputSchema: z.object({ inputSchema: z.object({
@ -1690,7 +1711,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => { execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try { try {
const { patchTask } = await import("../../background-tasks/fileops.js"); const { patchTask } = await import("../../background-tasks/fileops.js");
const { slug, projectDir, ...partial } = input; const { slug, projectDir, clearModel, ...partial } = input;
let warning: string | undefined; let warning: string | undefined;
if (projectDir) { if (projectDir) {
const r = await resolveCodeProject(projectDir); const r = await resolveCodeProject(projectDir);
@ -1698,7 +1719,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
(partial as { projectId?: string }).projectId = r.projectId; (partial as { projectId?: string }).projectId = r.projectId;
warning = r.warning; warning = r.warning;
} }
const result = await patchTask(slug, partial); const result = await patchTask(slug, partial, clearModel ? ['model', 'provider'] : []);
return { success: true, task: result, ...(warning ? { warning } : {}) }; return { success: true, task: result, ...(warning ? { warning } : {}) };
} catch (err) { } catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) }; return { success: false, error: err instanceof Error ? err.message : String(err) };

View file

@ -81,13 +81,20 @@ export async function fetchTask(slug: string): Promise<BackgroundTask | null> {
* structural edits (active toggle, instructions, triggers, model) and by the * structural edits (active toggle, instructions, triggers, model) and by the
* runner for the `lastRun*` runtime fields. * runner for the `lastRun*` runtime fields.
*/ */
export async function patchTask(slug: string, partial: Partial<BackgroundTask>): Promise<BackgroundTask> { export async function patchTask(
slug: string,
partial: Partial<BackgroundTask>,
clear: Array<keyof BackgroundTask> = [],
): Promise<BackgroundTask> {
return withFileLock(taskYamlPath(slug), async () => { return withFileLock(taskYamlPath(slug), async () => {
const current = await fetchTask(slug); const current = await fetchTask(slug);
if (!current) { if (!current) {
throw new Error(`Task '${slug}' not found`); throw new Error(`Task '${slug}' not found`);
} }
const next: BackgroundTask = { ...current, ...partial }; const next: BackgroundTask = { ...current, ...partial };
// Allow explicitly clearing a field (e.g. reset model → falls back to the
// default). A plain merge can't remove a key.
for (const key of clear) delete next[key];
await fs.writeFile(taskYamlPath(slug), stringifyYaml(next), 'utf-8'); await fs.writeFile(taskYamlPath(slug), stringifyYaml(next), 'utf-8');
return next; return next;
}); });