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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,28 @@ Use `mcode init .` to generate or update project guidance in `AGENTS.md`. Descri
| Headless | `mcode exec [prompt]` | Shell scripts, CI, batch work, and evaluations. |
| ACP | `mcode acp` | Editors and clients supporting Agent Client Protocol. |

### Lightweight conversations

For simple conversation or general knowledge questions, start a new Session with
`--mode lightweight`:

```bash
mcode --mode lightweight "Explain how DNS caching works."
mcode exec --mode lightweight "Summarize the CAP theorem."
```

Lightweight mode sends a small conversational system prompt and no tool schemas. It omits workspace
instructions, Skills, memory blocks, MCP schemas, and the full environment block from provider
context, so it cannot inspect or change local files. Start a new standard Session for coding or tool use with
`mcode --mode standard` or `mcode exec --mode standard`; omitting `--mode` is identical to standard
mode.

The mode is fixed when a root Session is created. `--mode lightweight` therefore cannot be combined
with `--session` or `--continue`. Child/sub-agent Sessions do not inherit it, `/compact` continues to
use the standard compaction context, and ACP and desktop Sessions remain standard. Reopening a
lightweight Session keeps its mode and shows `Lightweight` in the TUI status line; opening a standard
Session from a lightweight launch remains standard and shows no indicator.

### Continue your work

```bash
Expand Down
19 changes: 19 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ mcode "Find a failing test, fix the implementation, and run the relevant tests."
| Headless | `mcode exec [prompt]` | Shell、CI、批处理与评测。 |
| ACP | `mcode acp` | 支持 Agent Client Protocol 的编辑器与客户端。 |

### 轻量对话

对于简单对话或通用知识问答,可用 `--mode lightweight` 创建新会话:

```bash
mcode --mode lightweight "解释 DNS 缓存的工作原理。"
mcode exec --mode lightweight "概述 CAP 定理。"
```

轻量模式只发送精简的对话系统提示词,不发送工具 schema,并从提供方上下文中省略工作区
指令、Skills、memory 块、MCP schema 和完整环境块,因此不能检查或修改本地文件。需要编程或工具能力时,请使用
`mcode --mode standard` 或 `mcode exec --mode standard` 创建新的标准会话;省略 `--mode`
与标准模式完全一致。

模式在根会话创建时固定,因此 `--mode lightweight` 不能与 `--session` 或 `--continue`
同时使用。子代理/子会话不会继承轻量模式,`/compact` 仍使用标准压缩上下文,ACP 与桌面端
会话也始终使用标准模式。重新打开轻量会话时会保留该模式,并在 TUI 状态栏显示
`Lightweight`;从轻量模式启动的 TUI 打开标准会话时仍保持标准模式,且不显示该标记。

### 继续之前的工作

```bash
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/pi-turn-runner/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export function newAgent(turn: turnState): Agent {
...(turn.input.shouldStopAfterTurn
? { shouldStopAfterTurn: turn.input.shouldStopAfterTurn }
: {}),
...(turn.input.unexpectedToolCallFallback
? { unexpectedToolCallFallback: turn.input.unexpectedToolCallFallback }
: {}),
...(turn.llm.payloadTransform ? { onPayload: turn.llm.payloadTransform } : {}),
...(turn.llm.responseObserver ? { onResponse: turn.llm.responseObserver } : {}),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/pi-turn-runner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ export interface RunTurnInput<TCtx extends ToolExecutionContext = ToolExecutionC
shouldStopAfterSteering?: () => boolean | Promise<boolean>;
/** Gracefully ends after the current assistant/tool step and before any follow-up poll. */
shouldStopAfterTurn?: () => boolean | Promise<boolean>;
/** Text returned when a provider emits a tool call the host intentionally did not expose. */
unexpectedToolCallFallback?: string;
/**
* Atomically seals external continuation acceptance once steering is empty.
* `false` means work won the close race and the runner must poll once more.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ import {
createCompactionLifecycleMetadata,
createManualCompactionChange,
} from './compaction-history.js';
import { joinPrompt, readPreparedSystemPrompt } from '../execution/prompt.js';
import {
joinPrompt,
readPreparedSystemPrompt,
resolveProviderContextMode,
} from '../execution/prompt.js';
import { localPluginHookCoordinator } from '../assembly/local-turn-plugin-hooks.js';
import {
createAgentHostPluginHookTranscript,
Expand Down Expand Up @@ -507,6 +511,21 @@ export class AgentHostCompactionExecutor<
sessionId: input.lease.sessionId,
signal: input.lease.signal,
});
const providerContext = resolveProviderContextMode(
session,
{
systemPrompt: joinPrompt(
assembly.systemPromptPrefix,
readPreparedSystemPrompt(preparation.agentConfig),
),
tools: assembly.tools.map(({ def }) => ({
name: def.name,
description: def.description,
parameters: def.schema,
})),
},
'compaction',
);
const compaction = captureContextCompactionResult(
await compactionDependencies.manual.compactManual(
createManualCompactionRequest({
Expand All @@ -516,15 +535,8 @@ export class AgentHostCompactionExecutor<
thinkingLevel,
streamFn,
payloadTransform,
systemPrompt: joinPrompt(
assembly.systemPromptPrefix,
readPreparedSystemPrompt(preparation.agentConfig),
),
tools: assembly.tools.map(({ def }) => ({
name: def.name,
description: def.description,
parameters: def.schema,
})),
systemPrompt: providerContext.systemPrompt,
tools: [...providerContext.tools],
}),
{
onStarted: () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ import {
import { localPluginHookCoordinator } from "../assembly/local-turn-plugin-hooks.js";
import { NativeLocalTurnExecutionPreparationSource } from "../assembly/local-turn-execution-preparation.js";
import { LocalTurnInputPreparer } from "../assembly/local-turn-input-preparation.js";
import { LIGHTWEIGHT_SESSION_PURPOSE } from "@mavis/protocol/local";
import {
LIGHTWEIGHT_SYSTEM_PROMPT,
LIGHTWEIGHT_TOOL_CALL_FALLBACK,
resolveProviderContextMode,
} from "./prompt.js";

interface LocalToolContext extends ToolExecutionContext {
readonly permissionScope?: string;
Expand Down Expand Up @@ -1132,6 +1138,132 @@ describe("LocalRuntimeTurnExecutor Bash output capability", () => {
});

describe("LocalRuntimeTurnExecutor", () => {
it("keeps the standard provider context byte-for-byte and trims only explicit lightweight roots", async () => {
const tool = {
def: {
name: "fixture_tool",
description: "Fixture tool",
schema: { type: "object", properties: { value: { type: "string" } } },
},
impl: { execute: vi.fn() },
} as never;
const captures: LocalRuntimeTurnRunnerInput<LocalToolContext>[] = [];
const runner = new LocalRuntimeTurnExecutor(
options(async (runInput) => {
captures.push(runInput);
await runInput.eventWriter.pushRuntime(terminalEvent(RuntimeEventStatus.COMPLETED));
}),
);
const base = executionInput({
assembly: { ...assembly(), tools: [tool] },
});
const standardFixture =
'{"systemPrompt":"extension system\\n\\nbase system","tools":[{"name":"fixture_tool","description":"Fixture tool","schema":{"type":"object","properties":{"value":{"type":"string"}}}}]}';

await runner.execute(base);
const standard = captures.at(-1)!;
expect(
JSON.stringify({
systemPrompt: standard.systemPrompt,
tools: standard.tools?.map(({ def }) => ({
name: def.name,
description: def.description,
schema: def.schema,
})),
}),
).toBe(standardFixture);
expect(standard.unexpectedToolCallFallback).toBeUndefined();

await runner.execute(
executionInput({
assembly: { ...assembly(), tools: [tool] },
session: {
...base.session,
purpose: LIGHTWEIGHT_SESSION_PURPOSE,
},
}),
);
const lightweight = captures.at(-1)!;
expect(lightweight.systemPrompt).toBe(LIGHTWEIGHT_SYSTEM_PROMPT);
expect(lightweight.tools).toEqual([]);
expect(lightweight.contextUsagePromptRanges).toBeUndefined();
expect(lightweight.unexpectedToolCallFallback).toBe(LIGHTWEIGHT_TOOL_CALL_FALLBACK);
});

it("does not propagate lightweight mode to task or branch child Sessions", async () => {
const prompts: string[] = [];
const runner = new LocalRuntimeTurnExecutor(
options(async (runInput) => {
prompts.push(runInput.systemPrompt);
await runInput.eventWriter.pushRuntime(terminalEvent(RuntimeEventStatus.COMPLETED));
}),
);
const base = executionInput();
for (const session of [
{
...base.session,
purpose: LIGHTWEIGHT_SESSION_PURPOSE,
sessionType: "branch" as const,
sessionKind: "task" as const,
parentSessionId: "parent",
},
{
...base.session,
purpose: LIGHTWEIGHT_SESSION_PURPOSE,
sessionType: "branch" as const,
parentSessionId: "parent",
},
]) {
await runner.execute(executionInput({ session }));
}
expect(prompts).toEqual([
"extension system\n\nbase system",
"extension system\n\nbase system",
]);
});

it.each([
"code-review:context-mode:lightweight",
"context-mode:lightweight:im",
" context-mode:lightweight",
])("keeps mixed purpose %j on the standard tool surface", async (purpose) => {
let captured: LocalRuntimeTurnRunnerInput<LocalToolContext> | undefined;
const tool = {
def: { name: "read", description: "Read", schema: { type: "object" } },
impl: { execute: vi.fn() },
} as never;
const runner = new LocalRuntimeTurnExecutor(
options(async (runInput) => {
captured = runInput;
await runInput.eventWriter.pushRuntime(terminalEvent(RuntimeEventStatus.COMPLETED));
}),
);
const base = executionInput();
await runner.execute(
executionInput({
session: { ...base.session, purpose },
assembly: { ...assembly(), tools: [tool] },
}),
);
expect(captured?.tools?.map(({ def }) => def.name)).toEqual(["read"]);
expect(captured?.systemPrompt).toBe("extension system\n\nbase system");
});

it("keeps compaction on the standard provider context for a lightweight Session", () => {
const base = executionInput();
const context = {
systemPrompt: "full compaction system prompt",
tools: [{ name: "read" }],
};
expect(
resolveProviderContextMode(
{ ...base.session, purpose: LIGHTWEIGHT_SESSION_PURPOSE },
context,
"compaction",
),
).toBe(context);
});

it("projects runtime events before they reach the v2 commit pipeline", async () => {
const delivered: string[] = [];
const input = executionInput({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ import type {
import {
joinPrompt,
joinUserPrompt,
isLightweightRootSession,
LIGHTWEIGHT_TOOL_CALL_FALLBACK,
readPreparedContextUsagePromptRanges,
readPreparedSystemPrompt,
renderAgentRuntimeReminders,
resolveProviderContextMode,
} from './prompt.js';
import {
createAgentHostPluginHookTranscript,
Expand Down Expand Up @@ -365,6 +368,9 @@ export class LocalRuntimeTurnExecutor<
input.request.provenance.sourceContext?.executionDiagnostics === true,
),
caller: prepared.caller,
...(prepared.unexpectedToolCallFallback
? { unexpectedToolCallFallback: prepared.unexpectedToolCallFallback }
: {}),
getSteeringMessages: async (context) => {
for (;;) {
// Subscribe before draining so an append racing the wait cannot be missed.
Expand Down Expand Up @@ -470,6 +476,7 @@ export class LocalRuntimeTurnExecutor<
: { maxSerializedInputBytes: maxRequestBodyBytes }),
};
const baseSystemPrompt = readPreparedSystemPrompt(input.preparation.agentConfig);
const lightweightMode = isLightweightRootSession(input.session);
const preparedExecution = await this.options.executionPreparation.prepare({
execution: input,
eventWriter,
Expand All @@ -484,7 +491,7 @@ export class LocalRuntimeTurnExecutor<
(tool) =>
tool.def.name === 'task_output' && (tool.source === undefined || tool.source === 'builtin'),
);
const tools = prepareBashTurnTools(admittedTools, canConsumeBackgroundBashOutput);
const standardTools = prepareBashTurnTools(admittedTools, canConsumeBackgroundBashOutput);
const toolContext = {
...toolResolution.context,
...(input.pluginHooks?.length
Expand All @@ -496,12 +503,24 @@ export class LocalRuntimeTurnExecutor<
canConsumeBackgroundBashOutput,
} as TContext;
const caller = preparedExecution.caller;
const systemPrompt = joinPrompt(input.assembly.systemPromptPrefix, baseSystemPrompt);
const contextUsagePromptRanges = readPreparedContextUsagePromptRanges(
const standardSystemPrompt = joinPrompt(input.assembly.systemPromptPrefix, baseSystemPrompt);
const standardContextUsagePromptRanges = readPreparedContextUsagePromptRanges(
input.preparation.agentConfig,
input.assembly.systemPromptPrefix,
baseSystemPrompt,
);
const providerContext = resolveProviderContextMode(input.session, {
systemPrompt: standardSystemPrompt,
tools: standardTools,
...(standardContextUsagePromptRanges === undefined
? {}
: { contextUsagePromptRanges: standardContextUsagePromptRanges }),
});
const {
systemPrompt,
tools,
contextUsagePromptRanges,
} = providerContext;
input.onContextUsagePromptRangesResolved?.(contextUsagePromptRanges);
const userPromptPrefix = joinUserPrompt(
input.assembly.userPromptPrefix,
Expand Down Expand Up @@ -589,6 +608,11 @@ export class LocalRuntimeTurnExecutor<
toolContext,
beforeLlmCallHooks,
caller,
...(lightweightMode
? {
unexpectedToolCallFallback: LIGHTWEIGHT_TOOL_CALL_FALLBACK,
}
: {}),
...(contextUsagePromptRanges !== undefined
? {
contextUsagePromptRanges,
Expand Down Expand Up @@ -780,6 +804,7 @@ interface PreparedRunnerInput<TContext extends ToolExecutionContext> {
readonly toolContext: TContext;
readonly beforeLlmCallHooks: readonly PiBeforeLlmCallHook[];
readonly caller: RunTurnCaller;
readonly unexpectedToolCallFallback?: string;
readonly contextUsagePromptRanges?: readonly LocalContextUsagePromptRange[];
readonly contextUsageRequiresProviderAnchor?: boolean;
readonly readTaskOutputTaskIds: Set<string>;
Expand Down
Loading
Loading