sf snapshot: pre-dispatch, uncommitted changes after 97m inactivity
This commit is contained in:
parent
b26dca40ec
commit
8677e73046
24 changed files with 322 additions and 80 deletions
|
|
@ -166,7 +166,8 @@
|
|||
"@singularity-forge/engine-linux-x64-gnu": ">=2.10.2",
|
||||
"@singularity-forge/engine-win32-x64-msvc": ">=2.10.2",
|
||||
"fsevents": "~2.3.3",
|
||||
"koffi": "^2.9.0"
|
||||
"koffi": "^2.9.0",
|
||||
"vectordrive": "^0.1.35"
|
||||
},
|
||||
"overrides": {
|
||||
"gaxios": "^6.7.1"
|
||||
|
|
|
|||
|
|
@ -100,6 +100,110 @@ describe("agent-loop — pauseTurn handling (#2869)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("agent-loop — steering during tool batches", () => {
|
||||
it("defers queued steering until after the current tool batch when configured", async () => {
|
||||
const calls: string[] = [];
|
||||
const tool = {
|
||||
name: "record",
|
||||
label: "Record",
|
||||
description: "Record a value",
|
||||
parameters: Type.Object({ value: Type.String() }),
|
||||
execute: async (_id: string, args: { value: string }) => {
|
||||
calls.push(args.value);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `recorded ${args.value}` }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
} satisfies AgentTool<{ value: string }>;
|
||||
|
||||
const first = makeAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "tc-1",
|
||||
name: "record",
|
||||
arguments: { value: "one" },
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "tc-2",
|
||||
name: "record",
|
||||
arguments: { value: "two" },
|
||||
},
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
const second = makeAssistantMessage({
|
||||
content: [{ type: "text", text: "saw steering" }],
|
||||
stopReason: "stop",
|
||||
});
|
||||
const mockStream = createMockStreamFn([first, second]);
|
||||
let steeringPolls = 0;
|
||||
const steering: AgentMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "do not interrupt" }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are a test agent.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "record values" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const config: AgentLoopConfig = {
|
||||
model: TEST_MODEL,
|
||||
convertToLlm: (msgs) => msgs.filter((m): m is any => m.role !== "custom"),
|
||||
toolExecution: "sequential",
|
||||
interruptToolExecutionOnSteering: false,
|
||||
getSteeringMessages: async () => {
|
||||
steeringPolls += 1;
|
||||
return steeringPolls === 1 ? [steering] : [];
|
||||
},
|
||||
};
|
||||
|
||||
const stream = agentLoop(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "record values" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
context,
|
||||
config,
|
||||
undefined,
|
||||
mockStream as any,
|
||||
);
|
||||
|
||||
const events = await collectEvents(stream);
|
||||
const skipped = events.filter(
|
||||
(event) =>
|
||||
event.type === "tool_execution_end" &&
|
||||
JSON.stringify(event.result.content).includes(
|
||||
"Skipped due to queued user message",
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["one", "two"]);
|
||||
assert.equal(skipped.length, 0);
|
||||
assert.ok(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "message_start" && event.message === steering,
|
||||
),
|
||||
"queued steering should still be delivered after the tool batch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for #2783: Stuck-loop on execute-task — tool-call schema
|
||||
* overload causes unbounded retry + budget burn.
|
||||
|
|
|
|||
|
|
@ -517,6 +517,7 @@ async function executeToolCallsSequential(
|
|||
const results: ToolResultMessage[] = [];
|
||||
let steeringMessages: AgentMessage[] | undefined;
|
||||
let preparationErrorCount = 0;
|
||||
const interruptOnSteering = config.interruptToolExecutionOnSteering !== false;
|
||||
|
||||
for (let index = 0; index < toolCalls.length; index++) {
|
||||
const toolCall = toolCalls[index];
|
||||
|
|
@ -551,12 +552,14 @@ async function executeToolCallsSequential(
|
|||
if (config.getSteeringMessages) {
|
||||
const steering = await config.getSteeringMessages();
|
||||
if (steering.length > 0) {
|
||||
steeringMessages = steering;
|
||||
const remainingCalls = toolCalls.slice(index + 1);
|
||||
for (const skipped of remainingCalls) {
|
||||
results.push(skipToolCall(skipped, stream));
|
||||
steeringMessages = [...(steeringMessages ?? []), ...steering];
|
||||
if (interruptOnSteering) {
|
||||
const remainingCalls = toolCalls.slice(index + 1);
|
||||
for (const skipped of remainingCalls) {
|
||||
results.push(skipToolCall(skipped, stream));
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -576,6 +579,7 @@ async function executeToolCallsParallel(
|
|||
const runnableCalls: PreparedToolCall[] = [];
|
||||
let steeringMessages: AgentMessage[] | undefined;
|
||||
let preparationErrorCount = 0;
|
||||
const interruptOnSteering = config.interruptToolExecutionOnSteering !== false;
|
||||
|
||||
for (let index = 0; index < toolCalls.length; index++) {
|
||||
const toolCall = toolCalls[index];
|
||||
|
|
@ -599,15 +603,17 @@ async function executeToolCallsParallel(
|
|||
if (config.getSteeringMessages) {
|
||||
const steering = await config.getSteeringMessages();
|
||||
if (steering.length > 0) {
|
||||
steeringMessages = steering;
|
||||
for (const runnable of runnableCalls) {
|
||||
results.push(skipToolCall(runnable.toolCall, stream, { emitStart: false }));
|
||||
steeringMessages = [...(steeringMessages ?? []), ...steering];
|
||||
if (interruptOnSteering) {
|
||||
for (const runnable of runnableCalls) {
|
||||
results.push(skipToolCall(runnable.toolCall, stream, { emitStart: false }));
|
||||
}
|
||||
const remainingCalls = toolCalls.slice(index + 1);
|
||||
for (const skipped of remainingCalls) {
|
||||
results.push(skipToolCall(skipped, stream));
|
||||
}
|
||||
return { toolResults: results, steeringMessages, preparationErrorCount };
|
||||
}
|
||||
const remainingCalls = toolCalls.slice(index + 1);
|
||||
for (const skipped of remainingCalls) {
|
||||
results.push(skipToolCall(skipped, stream));
|
||||
}
|
||||
return { toolResults: results, steeringMessages, preparationErrorCount };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ export interface AgentOptions {
|
|||
*/
|
||||
steeringMode?: "all" | "one-at-a-time";
|
||||
|
||||
/**
|
||||
* Whether steering messages interrupt the current assistant tool batch.
|
||||
* Defaults to true for interactive sessions.
|
||||
*/
|
||||
interruptToolExecutionOnSteering?: boolean;
|
||||
|
||||
/**
|
||||
* Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn
|
||||
*/
|
||||
|
|
@ -161,6 +167,7 @@ export class Agent {
|
|||
private _afterToolCall?: AgentLoopConfig["afterToolCall"];
|
||||
private _externalToolExecution?: (model: Model<any>) => boolean;
|
||||
private _getProviderOptions?: AgentOptions["getProviderOptions"];
|
||||
private _interruptToolExecutionOnSteering: boolean;
|
||||
|
||||
constructor(opts: AgentOptions = {}) {
|
||||
this._state = { ...this._state, ...opts.initialState };
|
||||
|
|
@ -177,6 +184,8 @@ export class Agent {
|
|||
this._maxRetryDelayMs = opts.maxRetryDelayMs;
|
||||
this._externalToolExecution = opts.externalToolExecution;
|
||||
this._getProviderOptions = opts.getProviderOptions;
|
||||
this._interruptToolExecutionOnSteering =
|
||||
opts.interruptToolExecutionOnSteering ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -520,6 +529,7 @@ export class Agent {
|
|||
getFollowUpMessages: async () => this.dequeueFollowUpMessages(),
|
||||
beforeToolCall: this._beforeToolCall,
|
||||
afterToolCall: this._afterToolCall,
|
||||
interruptToolExecutionOnSteering: this._interruptToolExecutionOnSteering,
|
||||
externalToolExecution: this._externalToolExecution?.(model) ?? false,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|||
*/
|
||||
getSteeringMessages?: () => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Whether steering messages interrupt the current assistant tool batch.
|
||||
*
|
||||
* Default true preserves interactive behavior: a user message typed while
|
||||
* tools are running skips remaining tool calls and lets the model react
|
||||
* immediately. Headless/auto runners can set this false so incidental
|
||||
* queued messages are deferred until the current tool batch finishes.
|
||||
*/
|
||||
interruptToolExecutionOnSteering?: boolean;
|
||||
|
||||
/**
|
||||
* Returns follow-up messages to process after the agent would otherwise stop.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -605,18 +605,21 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// Process Kimi For Coding models
|
||||
// Process Kimi For Coding models. The kimi-coding endpoint is
|
||||
// permissive on model slug, so we surface a single canonical id
|
||||
// (kimi-k2.6) instead of the upstream "kimi-for-coding" alias to
|
||||
// match opencode-go's slug for the same Moonshot K2.6 model.
|
||||
if (data["kimi-for-coding"]?.models) {
|
||||
for (const [modelId, model] of Object.entries(data["kimi-for-coding"].models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
const canonicalId = modelId === "kimi-for-coding" ? "kimi-k2.6" : modelId;
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
id: canonicalId,
|
||||
name: m.name || canonicalId,
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi-coding",
|
||||
// Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages
|
||||
baseUrl: "https://api.kimi.com/coding",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
|
|
@ -1249,7 +1252,7 @@ async function generateModels() {
|
|||
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding";
|
||||
const kimiCodingModels: Model<"anthropic-messages">[] = [
|
||||
{
|
||||
id: "kimi-for-coding",
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi-coding",
|
||||
|
|
|
|||
|
|
@ -4616,8 +4616,8 @@ export const MODELS = {
|
|||
} satisfies Model<"openai-completions">,
|
||||
},
|
||||
"kimi-coding": {
|
||||
"kimi-for-coding": {
|
||||
id: "kimi-for-coding",
|
||||
"kimi-k2.6": {
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi-coding",
|
||||
|
|
|
|||
|
|
@ -436,12 +436,7 @@ const adapters: Record<string, ProviderDiscoveryAdapter> = {
|
|||
executionBaseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
}),
|
||||
"kimi-coding": new ProviderModelListAdapter({
|
||||
provider: "kimi-coding",
|
||||
discoveryBaseUrl: "https://api.kimi.com/coding/v1",
|
||||
executionBaseUrl: "https://api.kimi.com/coding",
|
||||
api: "anthropic-messages",
|
||||
}),
|
||||
"kimi-coding": new StaticDiscoveryAdapter("kimi-coding"),
|
||||
mistral: new ProviderModelListAdapter({
|
||||
provider: "mistral",
|
||||
discoveryBaseUrl: "https://api.mistral.ai/v1",
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||
},
|
||||
steeringMode: settingsManager.getSteeringMode(),
|
||||
followUpMode: settingsManager.getFollowUpMode(),
|
||||
interruptToolExecutionOnSteering: process.env.SF_HEADLESS !== "1",
|
||||
transport: settingsManager.getTransport(),
|
||||
thinkingBudgets: settingsManager.getThinkingBudgets(),
|
||||
maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const { getModel, streamSimpleOpenAICompletions } = await import("../packages/pi
|
|||
const modelIds = modelsArg
|
||||
? modelsArg.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [
|
||||
"kimi-coding/kimi-for-coding",
|
||||
"kimi-coding/kimi-k2.6",
|
||||
"minimax/MiniMax-M2.7-highspeed",
|
||||
"zai/glm-4.5",
|
||||
"mistral/devstral-latest",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const EXIT_SUCCESS = 0;
|
|||
export const EXIT_ERROR = 1;
|
||||
export const EXIT_BLOCKED = 10;
|
||||
export const EXIT_CANCELLED = 11;
|
||||
export const EXIT_RELOAD = 12;
|
||||
|
||||
/**
|
||||
* Map a headless session status string to its standardized exit code.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* 1 — error or timeout
|
||||
* 10 — blocked (command reported a blocker)
|
||||
* 11 — cancelled (SIGINT/SIGTERM received)
|
||||
* 12 — reload (agent requested restart-with-resume, same session)
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
EXIT_BLOCKED,
|
||||
EXIT_CANCELLED,
|
||||
EXIT_ERROR,
|
||||
EXIT_RELOAD,
|
||||
EXIT_SUCCESS,
|
||||
FIRE_AND_FORGET_METHODS,
|
||||
IDLE_TIMEOUT_MS,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ function readConfigs(): McpServerConfig[] {
|
|||
join(process.cwd(), ".sf", "mcp.json"),
|
||||
join(sfHome, "mcp.json"), // global: ~/.sf/mcp.json
|
||||
join(sfHome, "agent", "mcp.json"), // global: ~/.sf/agent/mcp.json (legacy alt)
|
||||
join(homedir(), ".mcp.json"), // user-global: ~/.mcp.json (Claude Code, npx, etc.)
|
||||
];
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,29 @@ interface ScaffoldFile {
|
|||
}
|
||||
|
||||
const SCAFFOLD_FILES: ScaffoldFile[] = [
|
||||
{
|
||||
path: ".siftignore",
|
||||
content: `.git/**
|
||||
.sf/**
|
||||
.bg-shell/**
|
||||
.pytest_cache/**
|
||||
.venv/**
|
||||
venv/**
|
||||
node_modules/**
|
||||
**/node_modules/**
|
||||
**/__pycache__/**
|
||||
*.pyc
|
||||
*.egg-info/**
|
||||
build/**
|
||||
dist/**
|
||||
target/**
|
||||
vendor/**
|
||||
coverage/**
|
||||
.cache/**
|
||||
tmp/**
|
||||
*.log
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: "AGENTS.md",
|
||||
content: `# Agent Map
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export async function closeoutUnit(
|
|||
startedAt: number,
|
||||
opts?: CloseoutOptions,
|
||||
): Promise<string | undefined> {
|
||||
const modelId = ctx.model?.id ?? "unknown";
|
||||
const provider = ctx.model?.provider;
|
||||
const id = ctx.model?.id;
|
||||
const modelId = provider && id ? `${provider}/${id}` : (id ?? "unknown");
|
||||
snapshotUnitMetrics(ctx, unitType, unitId, startedAt, modelId, opts);
|
||||
const activityFile = saveActivityLog(ctx, basePath, unitType, unitId);
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,12 @@ export function registerExecTools(pi: ExtensionAPI): void {
|
|||
],
|
||||
parameters: Type.Object({
|
||||
runtime: Type.Union(
|
||||
[Type.Literal("bash"), Type.Literal("node"), Type.Literal("python")],
|
||||
[
|
||||
Type.Literal("bash"),
|
||||
Type.Literal("node"),
|
||||
Type.Literal("python"),
|
||||
Type.Literal("python3"),
|
||||
],
|
||||
{ description: "Interpreter: bash (-c), node (-e), or python3 (-c)." },
|
||||
),
|
||||
script: Type.String({
|
||||
|
|
@ -92,7 +97,12 @@ export function registerExecTools(pi: ExtensionAPI): void {
|
|||
),
|
||||
runtime: Type.Optional(
|
||||
Type.Union(
|
||||
[Type.Literal("bash"), Type.Literal("node"), Type.Literal("python")],
|
||||
[
|
||||
Type.Literal("bash"),
|
||||
Type.Literal("node"),
|
||||
Type.Literal("python"),
|
||||
Type.Literal("python3"),
|
||||
],
|
||||
{
|
||||
description: "Restrict to one runtime.",
|
||||
},
|
||||
|
|
@ -139,4 +149,37 @@ export function registerExecTools(pi: ExtensionAPI): void {
|
|||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Kill the current pi-agent subprocess. In headless mode: the supervisor
|
||||
* (sf run-headless) will detect the non-zero exit and restart the agent (up to
|
||||
* max-restarts=3, default). In interactive TUI mode: the process exits and
|
||||
* the user restarts manually.
|
||||
*
|
||||
* Use this to force-reload extension code (e.g. after a `~/.mcp.json` change)
|
||||
* or to escape a stuck state that cannot be resolved via normal tool calls.
|
||||
*/
|
||||
pi.registerTool({
|
||||
name: "kill_agent",
|
||||
label: "Kill Agent",
|
||||
description:
|
||||
"Kill the current pi-agent subprocess so the supervisor restarts it with fresh code. " +
|
||||
"Use after updating extension config files (e.g. ~/.mcp.json) that require a process restart " +
|
||||
"to take effect. The supervisor will restart the agent automatically (headless mode) or " +
|
||||
"you restart manually (interactive TUI).",
|
||||
promptSnippet:
|
||||
"Kill and restart the pi-agent to reload fresh extension code",
|
||||
promptGuidelines: [
|
||||
"Use this only when normal tool calls cannot achieve the desired effect and a clean restart is needed.",
|
||||
"In headless mode: the supervisor will restart the agent automatically.",
|
||||
"In interactive TUI: you will need to restart sf manually.",
|
||||
],
|
||||
parameters: Type.Object({}),
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
||||
// Exit with 1 so headless supervisor treats it as a crash and restarts.
|
||||
// Exit 0 would cause headless to exit normally without restarting.
|
||||
process.exit(1);
|
||||
// unreachable
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -343,7 +343,17 @@ function isFreshMarker(
|
|||
): boolean {
|
||||
try {
|
||||
const stat = statSync(markerPath);
|
||||
return now - stat.mtimeMs < ttlMs;
|
||||
if (now - stat.mtimeMs >= ttlMs) return false;
|
||||
const parsed = JSON.parse(readFileSync(markerPath, "utf-8")) as {
|
||||
schemaVersion?: number;
|
||||
cwd?: string;
|
||||
args?: unknown;
|
||||
};
|
||||
return (
|
||||
parsed.schemaVersion === 2 &&
|
||||
Array.isArray(parsed.args) &&
|
||||
parsed.args.at(-2) === "."
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -403,7 +413,7 @@ export function ensureSiftIndexWarmup(
|
|||
options.retrieverTimeoutMs ??
|
||||
DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS,
|
||||
),
|
||||
projectRoot,
|
||||
".",
|
||||
options.query ?? DEFAULT_SIFT_WARMUP_QUERY,
|
||||
];
|
||||
|
||||
|
|
@ -413,8 +423,10 @@ export function ensureSiftIndexWarmup(
|
|||
markerPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
startedAt: new Date(now).toISOString(),
|
||||
command: detection.binaryPath,
|
||||
cwd: projectRoot,
|
||||
args,
|
||||
},
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { resolve } from "node:path";
|
|||
|
||||
export interface ExecSandboxRequest {
|
||||
/** Interpreter to use. */
|
||||
runtime: "bash" | "node" | "python";
|
||||
runtime: "bash" | "node" | "python" | "python3";
|
||||
/** Script body. Executed via the runtime's -c equivalent. */
|
||||
script: string;
|
||||
/** Optional purpose/label recorded in meta.json. */
|
||||
|
|
@ -122,6 +122,7 @@ function resolveCommand(runtime: ExecSandboxRequest["runtime"]): {
|
|||
case "node":
|
||||
return { cmd: process.execPath, args: ["-e"] };
|
||||
case "python":
|
||||
case "python3":
|
||||
return { cmd: "python3", args: ["-c"] };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"entries": [
|
||||
{ "provider": "kimi-coding", "model": "kimi-for-coding", "priority": 0 }
|
||||
{ "provider": "kimi-coding", "model": "kimi-k2.6", "priority": 0 }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,34 +35,6 @@ export { formatTokenCount } from "../shared/format-utils.js";
|
|||
|
||||
// ─── Learning Integration ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Infer provider from a model ID when not explicitly prefixed with "provider/".
|
||||
*/
|
||||
function inferProviderFromBareModelId(modelId: string): string {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (
|
||||
lower === "kimi-for-coding" ||
|
||||
lower === "kimi-k2-thinking"
|
||||
)
|
||||
return "kimi-coding";
|
||||
if (lower.startsWith("minimax-m")) return "ollama-cloud";
|
||||
if (lower.startsWith("minimax-") || modelId.startsWith("MiniMax-"))
|
||||
return "minimax";
|
||||
if (lower.startsWith("glm-")) return "zai";
|
||||
if (lower.startsWith("mimo-")) return "xiaomi";
|
||||
if (lower.startsWith("gemini-")) return "google-gemini-cli";
|
||||
if (
|
||||
lower.startsWith("magistral-") ||
|
||||
lower.startsWith("mistral-") ||
|
||||
lower.startsWith("devstral-") ||
|
||||
lower.startsWith("codestral-") ||
|
||||
lower.startsWith("ministral-") ||
|
||||
lower.startsWith("pixtral-")
|
||||
)
|
||||
return "mistral";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatAggregateModelIdentity(modelId: string): string {
|
||||
const slashIdx = modelId.indexOf("/");
|
||||
if (slashIdx === -1) return formatModelIdentity({ id: modelId });
|
||||
|
|
@ -81,13 +53,12 @@ async function recordUnitOutcome(unit: UnitMetrics): Promise<void> {
|
|||
|
||||
try {
|
||||
const { recordOutcome } = await import("./learning/outcome-recorder.mjs");
|
||||
let provider: string;
|
||||
const modelId = unit.model;
|
||||
if (modelId.includes("/")) {
|
||||
[provider] = modelId.split("/");
|
||||
} else {
|
||||
provider = inferProviderFromBareModelId(modelId);
|
||||
}
|
||||
const slashIdx = modelId.indexOf("/");
|
||||
// Unit metrics always carry full "provider/model" slugs upstream;
|
||||
// drop bare-id entries rather than guessing the provider.
|
||||
if (slashIdx === -1) return;
|
||||
const provider = modelId.slice(0, slashIdx);
|
||||
|
||||
recordOutcome(db, {
|
||||
modelId,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ test("ensureAgenticDocsScaffold creates repo docs and nested AGENTS files withou
|
|||
readFileSync(join(dir, "docs", "QUALITY_SCORE.md"), "utf-8"),
|
||||
/Make code legible to agents/,
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(join(dir, ".siftignore"), "utf-8"),
|
||||
/\*\*\/node_modules\/\*\*/,
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(join(dir, ".siftignore"), "utf-8"),
|
||||
/\.venv\/\*\*/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,9 +354,9 @@ test("ensureSiftIndexWarmup starts page-index-hybrid warmup and writes marker",
|
|||
const projectRoot = makeProject();
|
||||
try {
|
||||
const fakeSift = writeFakeSiftBinary(projectRoot);
|
||||
const calls: Array<{ command: string; args: string[] }> = [];
|
||||
const fakeSpawn = ((command: string, args: string[]) => {
|
||||
calls.push({ command, args });
|
||||
const calls: Array<{ command: string; args: string[]; cwd?: string }> = [];
|
||||
const fakeSpawn = ((command: string, args: string[], options?: { cwd?: string }) => {
|
||||
calls.push({ command, args, cwd: options?.cwd });
|
||||
return { unref() {} };
|
||||
}) as unknown as typeof import("node:child_process").spawn;
|
||||
|
||||
|
|
@ -378,7 +378,8 @@ test("ensureSiftIndexWarmup starts page-index-hybrid warmup and writes marker",
|
|||
"1",
|
||||
"--retriever-timeout-ms",
|
||||
]);
|
||||
assert.equal(calls[0].args.at(-2), projectRoot);
|
||||
assert.equal(calls[0].args.at(-2), ".");
|
||||
assert.equal(calls[0].cwd, projectRoot);
|
||||
assert.match(calls[0].args.at(-1) ?? "", /repo architecture/);
|
||||
assert.match(
|
||||
readFileSync(
|
||||
|
|
@ -446,6 +447,46 @@ test("ensureSiftIndexWarmup skips recent marker and explicit non-sift backends",
|
|||
}
|
||||
});
|
||||
|
||||
test("ensureSiftIndexWarmup ignores stale absolute-path warmup markers", () => {
|
||||
const projectRoot = makeProject();
|
||||
try {
|
||||
writeFakeSiftBinary(projectRoot);
|
||||
mkdirSync(join(projectRoot, ".sf", "runtime"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectRoot, ".sf", "runtime", "sift-index-warmup.json"),
|
||||
`${JSON.stringify({
|
||||
startedAt: "2026-04-30T12:00:00.000Z",
|
||||
command: join(projectRoot, "bin", "sift"),
|
||||
args: ["search", projectRoot, "repo architecture"],
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
let spawnCount = 0;
|
||||
const fakeSpawn = (() => {
|
||||
spawnCount += 1;
|
||||
return { unref() {} };
|
||||
}) as unknown as typeof import("node:child_process").spawn;
|
||||
|
||||
const result = ensureSiftIndexWarmup(projectRoot, undefined, {
|
||||
env: { PATH: join(projectRoot, "bin") },
|
||||
spawnFn: fakeSpawn,
|
||||
now: Date.parse("2026-04-30T12:01:00.000Z"),
|
||||
});
|
||||
|
||||
assert.equal(result.status, "started");
|
||||
assert.equal(spawnCount, 1);
|
||||
assert.match(
|
||||
readFileSync(
|
||||
join(projectRoot, ".sf", "runtime", "sift-index-warmup.json"),
|
||||
"utf-8",
|
||||
),
|
||||
/"schemaVersion": 2/,
|
||||
);
|
||||
} finally {
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSiftBinary honors SIFT_PATH before PATH lookup", () => {
|
||||
const projectRoot = makeProject();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -121,9 +121,9 @@ export async function executeSfExec(
|
|||
if (!isContextModeEnabled(deps.preferences)) return disabledResult();
|
||||
|
||||
const runtime = params.runtime;
|
||||
if (runtime !== "bash" && runtime !== "node" && runtime !== "python") {
|
||||
if (runtime !== "bash" && runtime !== "node" && runtime !== "python" && runtime !== "python3") {
|
||||
return paramError(
|
||||
`invalid runtime "${String(runtime)}" — must be bash | node | python`,
|
||||
`invalid runtime "${String(runtime)}" — must be bash | node | python | python3`,
|
||||
);
|
||||
}
|
||||
const script = typeof params.script === "string" ? params.script : "";
|
||||
|
|
|
|||
|
|
@ -1070,9 +1070,12 @@ async function runSingleAgent(
|
|||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const extensionArgs = bundledPaths.flatMap((p) => ["--extension", p]);
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[process.env.SF_BIN_PATH!, ...extensionArgs, ...args],
|
||||
// Execute SF_BIN_PATH directly — it is an executable shell script (sf-from-source)
|
||||
// with a proper shebang. Do NOT pass it to process.execPath as a node script arg,
|
||||
// otherwise Node parses the bash file as JavaScript and fails with a syntax error.
|
||||
const proc = spawn(
|
||||
process.env.SF_BIN_PATH!,
|
||||
[...extensionArgs, ...args],
|
||||
{
|
||||
cwd: cwd ?? defaultCwd,
|
||||
shell: false,
|
||||
|
|
@ -1247,8 +1250,12 @@ async function runSingleAgentInCmuxSplit(
|
|||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const extensionArgs = bundledPaths.flatMap((p) => ["--extension", p]);
|
||||
// SF_BIN_PATH is an executable shell script with a shebang.
|
||||
// Execute it directly — do NOT pass it to node as a module arg (node would
|
||||
// try to parse the shell script as JavaScript and fail with a syntax error).
|
||||
// The OS honors the shebang when the file is exec'd directly.
|
||||
const sfBinPath = process.env.SF_BIN_PATH!;
|
||||
const processArgs = [
|
||||
process.env.SF_BIN_PATH!,
|
||||
...extensionArgs,
|
||||
...buildSubagentProcessArgs(agent, task, tmpPromptPath, modelOverride),
|
||||
];
|
||||
|
|
@ -1259,7 +1266,7 @@ async function runSingleAgentInCmuxSplit(
|
|||
const innerScript = [
|
||||
`cd ${bashPath(cwd ?? defaultCwd)}`,
|
||||
"set -o pipefail",
|
||||
`${bashPath(process.execPath)} ${processArgs.map((a) => bashPath(a)).join(" ")} 2> >(tee ${bashPath(stderrPath)} >&2) | tee ${bashPath(stdoutPath)}`,
|
||||
`${bashPath(sfBinPath)} ${processArgs.map((a) => bashPath(a)).join(" ")} 2> >(tee ${bashPath(stderrPath)} >&2) | tee ${bashPath(stdoutPath)}`,
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — bash variable syntax
|
||||
"status=${PIPESTATUS[0]}",
|
||||
`printf '%s' "$status" > ${bashPath(exitPath)}`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue