Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.details.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,25 @@ The role routing applies `high` to Scout/Review and `medium` to Implement:
}
```

An optional `efforts` object overrides the per-role reasoning defaults —
Scout `high`, Implement `medium`, Review `high` — with `medium`, `high`, or
`xhigh`:

```json
"efforts": {
"implement": "xhigh",
"scout": "medium"
}
```

Omitted roles keep the defaults, and existing attempts keep their recorded
effort on restart. A configured effort the routed models do not support
falls back to the role default with a diagnostic instead of failing the run;
when only a stronger fallback model supports it, routing advances along the
normal profile chain (for example Implement moves from Terra to Sol for
`xhigh`). Raise effort for hard implementation work and lower it for cheap
scouting when your provider and workload justify it.

For advanced Claude or GLM setup, configure the bundled Pi CLI under the daemon
account with `bun x --no-install pi`. Use its `/login` and `/model` commands where
supported and save the default. Provider keys must be in the daemon environment.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ acceptance evidence, and review PRs in GitHub. The board reads shared checkpoint
it does not launch another worker or grant approval.

New Codex setups select GPT-6 Astra; existing model settings are preserved.
New Scout/Review attempts use `high` reasoning; Implement uses `medium`.
New Scout/Review attempts use `high` reasoning; Implement uses `medium`. An
optional `efforts` object in `~/.config/roc/settings.json` overrides these
per-role defaults with `medium`, `high`, or `xhigh` (for example
`"efforts": { "implement": "xhigh" }`); an effort the routed models do not
support falls back to the role default with a diagnostic.
Roc uses Pi's tools and agent loop. It does not launch Codex CLI or Claude Code.

Pi automatically summarizes older context as a session approaches its context
Expand Down
9 changes: 8 additions & 1 deletion src/agents/pi/backend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { AgileError } from "../../runtime/errors";
import type { CatalogModel, ModelMapping } from "../../scheduler/model-routing";
import type {
CatalogModel,
ModelMapping,
RoleEfforts,
} from "../../scheduler/model-routing";
import { loadRocSettings } from "../../settings";
import {
buildDefaultSkillConfig,
Expand Down Expand Up @@ -88,6 +92,7 @@ export const startPiBackend: BackendFactory = async (context) => {
skillPaths,
allowUnsandboxed,
models: settings.models,
efforts: settings.efforts,
})(context);
};

Expand All @@ -101,6 +106,7 @@ export function buildPiBackendFactory(input: {
skillPaths?: readonly string[];
allowUnsandboxed?: boolean;
models?: ModelMapping;
efforts?: RoleEfforts;
startAttemptClient?: (cwd: string) => Promise<PiClientApi>;
}): BackendFactory {
return async ({ branches }: { branches: TaskBranchManager }) => {
Expand Down Expand Up @@ -236,6 +242,7 @@ export function buildPiBackendFactory(input: {
return {
catalog,
modelMapping,
efforts: input.efforts,
harness: createPiHarness({ branches, startClient: startAttemptClient }),
close: () => {
closed ??= (async () => {
Expand Down
8 changes: 7 additions & 1 deletion src/agents/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { AgentHarness } from "../harness/contracts";
import type { CatalogModel, ModelMapping } from "../scheduler/model-routing";
import type {
CatalogModel,
ModelMapping,
RoleEfforts,
} from "../scheduler/model-routing";
import type { TaskBranchManager } from "../workspace/task-branch";

/**
Expand All @@ -16,6 +20,8 @@ export type BackendRuntime = {
* catalog instead of inferring profiles from provider model names.
*/
readonly modelMapping?: ModelMapping;
/** Per-role reasoning effort overrides from Roc settings; unset roles keep defaults. */
readonly efforts?: RoleEfforts;
readonly harness: AgentHarness;
/** Releases owned resources idempotently, rejecting if child exit or cleanup cannot be confirmed. */
close(): Promise<void>;
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ async function executeOnboard(
...(priorSettings?.models === undefined
? {}
: { models: priorSettings.models }),
...(priorSettings?.efforts === undefined
? {}
: { efforts: priorSettings.efforts }),
},
homeRoot,
);
Expand Down
9 changes: 7 additions & 2 deletions src/cli/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,20 +161,25 @@ export async function runBackendSession(
retain = false;
stop.throwIfAborted();
const lastProgress = new Map<number, string>();
const emitDiagnostic = (message: string) =>
process.stderr.write(`${message}\n`);
const runner = new GitHubTaskPool({
concurrency: input.concurrency,
autoMerge: input.autoMerge,
store,
branches,
harness: backend.harness,
advisor: createModelAdvisor(backend.catalog, backend.modelMapping),
advisor: createModelAdvisor(backend.catalog, backend.modelMapping, {
efforts: backend.efforts,
onDiagnostic: emitDiagnostic,
}),
publisher:
options.publisherFactory?.(branches) ??
new GitHubPullRequestPublisher(baseBranch, branches, command),
command,
cwd: input.repoPath,
baseBranch,
diagnostic: (message) => process.stderr.write(`${message}\n`),
diagnostic: emitDiagnostic,
/** Emits confirmed phase changes and wait reasons once while tool activity stays local. */
progress(record) {
const summary = `Phase: ${record.phase}${record.failure ? ` · ${record.failure}` : ""}`;
Expand Down
2 changes: 2 additions & 0 deletions src/domain/agile-cycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { AgentRoleSchema, ReasoningEffortSchema } from "../harness/contracts";
import { ModelProfileSchema } from "./schemas";
import { SkillSettingsSchema } from "./skill-allowlist";

Expand Down Expand Up @@ -45,6 +46,7 @@ export const RocSettingsSchema = z
models: z
.partialRecord(ModelProfileSchema, z.string().regex(/^[^\s/]+\/[^\s]+$/u))
.optional(),
efforts: z.partialRecord(AgentRoleSchema, ReasoningEffortSchema).optional(),
})
.strict();

Expand Down
85 changes: 69 additions & 16 deletions src/scheduler/model-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
import type { ModelProfileSchema } from "../domain/schemas";

export type ModelProfile = z.infer<typeof ModelProfileSchema>;
export type AgentRole = "scout" | "implement" | "review";
/** Every Roc effort is also a Pi thinking level; no other levels exist. */
export type ReasoningEffort = "medium" | "high" | "xhigh";
export type CatalogModel = Readonly<{
id: string;
supportedReasoningEfforts: readonly string[];
}>;
export type ModelMapping = Readonly<Partial<Record<ModelProfile, string>>>;
/** Per-role configured efforts from Roc settings; unset roles keep defaults. */
export type RoleEfforts = Readonly<Partial<Record<AgentRole, ReasoningEffort>>>;
export type AdvisorInput = {
role: "scout" | "implement" | "review";
role: AgentRole;
risk: "low" | "medium" | "high";
retryIndex: 0 | 1 | 2;
priorProfile?: ModelProfile;
Expand All @@ -17,11 +22,16 @@
export type Route = {
profile: ModelProfile;
model: string;
effort: "medium" | "high";
effort: ReasoningEffort;
fallbacks: string[];
rationale: string[];
};
export type ModelAdvisor = { decide(input: AdvisorInput): Route | undefined };
export type AdvisorOptions = Readonly<{
efforts?: RoleEfforts;
/** Receives one bounded message per role when its configured effort is unsupported. */
onDiagnostic?: (message: string) => void;
}>;

const profileOrder: ModelProfile[] = ["luna", "terra", "sol"];

Expand All @@ -46,12 +56,12 @@
input.retryIndex === 2 || input.priorErrorCode === "model_unavailable";
if (!shouldUpgrade) return profilesFrom(input.priorProfile);
return profilesFrom(
profileOrder[
Math.min(
profileOrder.indexOf(input.priorProfile) + 1,
profileOrder.length - 1,
)
]!,

Check warning on line 64 in src/scheduler/model-routing.ts

View workflow job for this annotation

GitHub Actions / Lint and format

lint/style/noNonNullAssertion

Forbidden non-null assertion.
);
}

Expand All @@ -76,16 +86,44 @@
];
}

/** Explains how the effective effort relates to the configured one. */
function effortRationale(
configured: ReasoningEffort | undefined,
applied: ReasoningEffort,
): string[] {
if (configured === undefined) return [];
if (configured === applied) return [`effort ${applied} (configured)`];
return [
`configured effort ${configured} unsupported`,
`effort ${applied} (default)`,
];
}

/** Creates a model advisor from a stable catalog snapshot and optional mappings. */
export function createModelAdvisor(
catalog: readonly CatalogModel[],
mapping: ModelMapping = {},
options: AdvisorOptions = {},
): ModelAdvisor {
const catalogSnapshot = catalog.map((model) => ({
id: model.id,
supportedReasoningEfforts: [...model.supportedReasoningEfforts],
}));
const mappingSnapshot: ModelMapping = { ...mapping };
const effortsSnapshot: RoleEfforts = { ...(options.efforts ?? {}) };
const reportedRoles = new Set<AgentRole>();
/** Reports an unsupported configured effort once per role, never failing the run. */
const reportUnsupported = (
role: AgentRole,
configured: ReasoningEffort,
fallback: ReasoningEffort,
): void => {
if (reportedRoles.has(role)) return;
reportedRoles.add(role);
options.onDiagnostic?.(
`Configured ${role} effort "${configured}" is unsupported by the routed models; using the default "${fallback}" instead.`,
);
};
/** Finds the configured or inferred catalog model supporting a profile and effort. */
const modelForProfile = (
profile: ModelProfile,
Expand All @@ -109,22 +147,37 @@
return {
/** Chooses the first compatible routed model and records its fallbacks and rationale. */
decide(input) {
const effort: Route["effort"] =
const defaultEffort: ReasoningEffort =
input.role === "implement" ? "medium" : "high";
const choices = routeProfiles(input).flatMap((profile) => {
const model = modelForProfile(profile, effort);
return model === undefined ? [] : [{ profile, model }];
});
const choice = choices[0];
if (choice === undefined) return undefined;
const configured = effortsSnapshot[input.role];
const candidates: ReasoningEffort[] =
configured !== undefined && configured !== defaultEffort
? [configured, defaultEffort]
: [defaultEffort];
for (const effort of candidates) {
const choices = routeProfiles(input).flatMap((profile) => {
const model = modelForProfile(profile, effort);
return model === undefined ? [] : [{ profile, model }];
});
const choice = choices[0];
if (choice === undefined) {
if (effort !== defaultEffort)
reportUnsupported(input.role, effort, defaultEffort);
continue;
}

return {
profile: choice.profile,
model: choice.model,
effort,
fallbacks: choices.slice(1).map((fallback) => fallback.model),
rationale: routeRationale(input, choice.profile),
};
return {
profile: choice.profile,
model: choice.model,
effort,
fallbacks: choices.slice(1).map((fallback) => fallback.model),
rationale: [
...routeRationale(input, choice.profile),
...effortRationale(configured, effort),
],
};
}
return undefined;
},
};
}
Expand Down
4 changes: 4 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const publicFieldNames = new Set([
"luna",
"terra",
"sol",
"efforts",
"scout",
"implement",
"review",
]);
const repairGuidance =
"Back up this file, then repair it manually using the supported settings format in README.details.md (Progress and recovery) and retry; onboarding reads the same file and cannot repair it.";
Expand Down
18 changes: 18 additions & 0 deletions test/agents/pi/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,24 @@ test("saved execution consent permits the probe without an environment override"
}
});

test("surfaces configured per-role efforts on the returned runtime", async () => {
const probe = new ScriptedProbeClient(probeModels, probeDefaultModel);
const runtime = await buildPiBackendFactory({
allowUnsandboxed: true,
efforts: { implement: "xhigh", scout: "medium" },
startProbeClient: async () => probe,
})({ branches: memoryBranches() });
try {
expect(runtime.efforts).toEqual({
implement: "xhigh",
scout: "medium",
});
} finally {
await runtime.close();
}
expect(probe.closeCount).toBe(1);
});

test("the factory fails closed and closes the probe when no default model resolves", async () => {
const previous = process.env.ROC_ALLOW_UNSANDBOXED;
const probe = new ScriptedProbeClient(probeModels, null);
Expand Down
Loading
Loading