Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c392d3
feat: migrate CLI to TUI-based interface
minpeter Feb 22, 2026
09f90e7
feat: improve CLI command UX and stabilize continuation loops
minpeter Feb 22, 2026
73d98fd
refactor: remove unused new command factory
minpeter Feb 22, 2026
ea8bd93
refactor: remove unused stdin buffer module
minpeter Feb 22, 2026
aa1042d
refactor: remove unused legacy stream renderer
minpeter Feb 22, 2026
1a522f3
refactor: remove unused color print helpers
minpeter Feb 22, 2026
c83a8a1
refactor: remove unused system reminder formatter
minpeter Feb 22, 2026
afc53c8
refactor: remove unused platform helper exports
minpeter Feb 22, 2026
c024f99
refactor: remove unused skill id helper export
minpeter Feb 22, 2026
af51daa
refactor: make tool fallback type guard internal
minpeter Feb 22, 2026
1b23640
refactor: remove unused shared tmux singleton export
minpeter Feb 22, 2026
7b085d1
refactor: make toggle command config type internal
minpeter Feb 22, 2026
1c38527
refactor: remove unused legacy safety utility exports
minpeter Feb 22, 2026
e43c444
refactor: remove unused shell command error type
minpeter Feb 22, 2026
e8436c6
refactor: internalize tmux helper interfaces
minpeter Feb 22, 2026
eb679eb
refactor: internalize wrapper result type
minpeter Feb 22, 2026
9d73726
refactor: internalize file readability helpers
minpeter Feb 22, 2026
0217c73
backup
minpeter Feb 22, 2026
e02cd02
feat: refine read_file preview rendering
minpeter Feb 23, 2026
da8b3f2
feat: add skill command prefix system and stream cancellation
minpeter Feb 23, 2026
d633911
feat: add read-style glob_files preview rendering
minpeter Feb 23, 2026
52c9074
feat: add grep preview mode and simplify glob output
minpeter Feb 23, 2026
88b6722
fix: reliable Ctrl+C double-press exit and robust process cleanup
minpeter Feb 23, 2026
95e5661
docs: add stepCountIs documentation and architecture section in README
minpeter Feb 23, 2026
71c12fa
fix: address PR review feedback
minpeter Feb 23, 2026
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ Available commands:
You: ^C
```

## Architecture

The agent uses the Vercel AI SDK's `streamText` API with `stopWhen: stepCountIs(n)` for step control. This replaces the older `maxSteps` / `continueUntil` pattern—`stepCountIs` provides a clearer, declarative way to limit tool-call round-trips (e.g., `stepCountIs(1)` allows one tool invocation cycle per stream call). The outer conversation loop in the CLI drives multi-turn interaction.

## Model

Uses `LGAI-EXAONE/K-EXAONE-236B-A23B` via FriendliAI serverless endpoints by default. Use `/model` command to switch models.
Expand Down
856 changes: 6 additions & 850 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@
"@ai-sdk/anthropic": "^3.0.46",
"@ai-sdk/openai-compatible": "^2.0.30",
"@friendliai/ai-provider": "^1.1.4",
"@mariozechner/pi-tui": "^0.54.0",
"@t3-oss/env-core": "^0.13.10",
"ai": "^6.0.94",
"ai-sdk-provider-gemini-cli": "^2.0.1",
"glob": "^13.0.6",
"ignore": "^7.0.5",
"yaml": "^2.8.2",
Expand Down
156 changes: 69 additions & 87 deletions src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createFriendli } from "@friendliai/ai-provider";
import type { ModelMessage } from "ai";
import { ToolLoopAgent, wrapLanguageModel } from "ai";
import { createGeminiProvider } from "ai-sdk-provider-gemini-cli";
import { stepCountIs, streamText, wrapLanguageModel } from "ai";
import { getEnvironmentContext } from "./context/environment-context";
import { loadSkillsMetadata } from "./context/skills";
import { SYSTEM_PROMPT } from "./context/system-prompt";
Expand All @@ -12,41 +11,34 @@ import {
buildTodoContinuationPrompt,
getIncompleteTodos,
} from "./middleware/todo-continuation";
import {
DEFAULT_TOOL_FALLBACK_MODE,
LEGACY_ENABLED_TOOL_FALLBACK_MODE,
type ToolFallbackMode,
} from "./tool-fallback-mode";
import { tools } from "./tools";

export const DEFAULT_MODEL_ID = "MiniMaxAI/MiniMax-M2.5";
export const DEFAULT_ANTHROPIC_MODEL_ID = "claude-sonnet-4-5-20250929";
export const DEFAULT_GEMINI_MODEL_ID = "gemini-2.5-pro";
export const DEFAULT_ANTHROPIC_MODEL_ID = "claude-sonnet-4-6";
const OUTPUT_TOKEN_MAX = 64_000;

export type ProviderType = "friendli" | "anthropic" | "gemini";
type CoreStreamResult = ReturnType<typeof streamText>;

export const ANTHROPIC_MODELS = [
{ id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5 (Latest)" },
{ id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Latest)" },
] as const;
export interface AgentStreamOptions {
abortSignal?: AbortSignal;
}

export interface AgentStreamResult {
finishReason: CoreStreamResult["finishReason"];
fullStream: CoreStreamResult["fullStream"];
response: CoreStreamResult["response"];
}

export type ProviderType = "friendli" | "anthropic";

export const GEMINI_MODELS = [
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro Preview",
thinkingType: "thinkingLevel",
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",
thinkingType: "thinkingLevel",
},
{
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
thinkingType: "thinkingBudget",
},
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
thinkingType: "thinkingBudget",
},
export const ANTHROPIC_MODELS = [
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Latest)" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (Latest)" },
] as const;

const friendli = env.FRIENDLI_TOKEN
Expand All @@ -62,35 +54,14 @@ const anthropic = env.ANTHROPIC_API_KEY
})
: null;

const gemini = createGeminiProvider({ authType: "oauth-personal" });

type GeminiModel = (typeof GEMINI_MODELS)[number];

function getGeminiThinkingConfig(
model: GeminiModel | undefined,
enabled: boolean
) {
if (!enabled) {
return {};
}
if (model?.thinkingType === "thinkingLevel") {
return { thinkingConfig: { thinkingLevel: "medium" as const } };
}
return { thinkingConfig: { thinkingBudget: 10_000 } };
}

interface CreateAgentOptions {
enableThinking?: boolean;
enableToolFallback?: boolean;
instructions?: string;
provider?: ProviderType;
toolFallbackMode?: ToolFallbackMode;
}

const getModel = (
modelId: string,
provider: ProviderType,
thinkingEnabled = false
) => {
const getModel = (modelId: string, provider: ProviderType) => {
if (provider === "anthropic") {
if (!anthropic) {
throw new Error(
Expand All @@ -100,15 +71,6 @@ const getModel = (
return anthropic(modelId);
}

if (provider === "gemini") {
const geminiModel = GEMINI_MODELS.find((m) => m.id === modelId);
const thinkingConfig = getGeminiThinkingConfig(
geminiModel,
thinkingEnabled
);
return gemini(modelId, thinkingConfig);
}

if (!friendli) {
throw new Error(
"FRIENDLI_TOKEN is not set. Please set it in your environment."
Expand All @@ -123,15 +85,13 @@ const ANTHROPIC_MAX_OUTPUT_TOKENS = 64_000;
const createAgent = (modelId: string, options: CreateAgentOptions = {}) => {
const provider = options.provider ?? "friendli";
const thinkingEnabled = options.enableThinking ?? false;
const model = getModel(modelId, provider, thinkingEnabled);
const model = getModel(modelId, provider);

const getAnthropicProviderOptions = () => {
if (!thinkingEnabled) {
return undefined;
}

// Opus 4.5: use effort parameter
// Sonnet 4.5: use thinking with budgetTokens
const isOpus = modelId.includes("opus");
if (isOpus) {
return { anthropic: { effort: "high" } };
Expand All @@ -150,10 +110,6 @@ const createAgent = (modelId: string, options: CreateAgentOptions = {}) => {
if (provider === "anthropic") {
return getAnthropicProviderOptions();
}
if (provider === "gemini") {
// Gemini thinking is configured in the model creation
return undefined;
}
return {
friendli: {
chat_template_kwargs: {
Expand All @@ -173,18 +129,33 @@ const createAgent = (modelId: string, options: CreateAgentOptions = {}) => {
? ANTHROPIC_MAX_OUTPUT_TOKENS - ANTHROPIC_THINKING_BUDGET_TOKENS
: OUTPUT_TOKEN_MAX;

return new ToolLoopAgent({
model: wrapLanguageModel({
model,
middleware: buildMiddlewares({
enableToolFallback: options.enableToolFallback ?? false,
}),
const wrappedModel = wrapLanguageModel({
model,
middleware: buildMiddlewares({
toolFallbackMode: options.toolFallbackMode ?? DEFAULT_TOOL_FALLBACK_MODE,
}),
instructions: options.instructions || SYSTEM_PROMPT,
tools,
maxOutputTokens,
providerOptions,
});

return {
stream: ({
messages,
abortSignal,
}: { messages: ModelMessage[] } & AgentStreamOptions) => {
return streamText({
model: wrappedModel,
system: options.instructions ?? SYSTEM_PROMPT,
tools,
messages,
maxOutputTokens,
providerOptions,
// stepCountIs(n) replaces the deprecated maxSteps option.
// It configures the stream to stop after n tool-call round-trips,
// giving the model a single tool invocation cycle before returning.
stopWhen: stepCountIs(1),
abortSignal,
});
},
};
};

export type ModelType = "serverless" | "dedicated";
Expand All @@ -195,7 +166,7 @@ class AgentManager {
private provider: ProviderType = "friendli";
private headlessMode = false;
private thinkingEnabled = false;
private toolFallbackEnabled = false;
private toolFallbackMode: ToolFallbackMode = DEFAULT_TOOL_FALLBACK_MODE;

getModelId(): string {
return this.modelId;
Expand All @@ -221,8 +192,6 @@ class AgentManager {
this.provider = provider;
if (provider === "anthropic") {
this.modelId = DEFAULT_ANTHROPIC_MODEL_ID;
} else if (provider === "gemini") {
this.modelId = DEFAULT_GEMINI_MODEL_ID;
} else {
this.modelId = DEFAULT_MODEL_ID;
}
Expand All @@ -244,12 +213,22 @@ class AgentManager {
return this.thinkingEnabled;
}

getToolFallbackMode(): ToolFallbackMode {
return this.toolFallbackMode;
}

setToolFallbackMode(mode: ToolFallbackMode): void {
this.toolFallbackMode = mode;
}

setToolFallbackEnabled(enabled: boolean): void {
this.toolFallbackEnabled = enabled;
this.toolFallbackMode = enabled
? LEGACY_ENABLED_TOOL_FALLBACK_MODE
: DEFAULT_TOOL_FALLBACK_MODE;
}

isToolFallbackEnabled(): boolean {
return this.toolFallbackEnabled;
return this.toolFallbackMode !== DEFAULT_TOOL_FALLBACK_MODE;
}

async getInstructions(): Promise<string> {
Expand All @@ -272,14 +251,17 @@ class AgentManager {
return tools;
}

async stream(messages: ModelMessage[]) {
async stream(
messages: ModelMessage[],
options: AgentStreamOptions = {}
): Promise<AgentStreamResult> {
const agent = createAgent(this.modelId, {
instructions: await this.getInstructions(),
enableThinking: this.thinkingEnabled,
enableToolFallback: this.toolFallbackEnabled,
toolFallbackMode: this.toolFallbackMode,
provider: this.provider,
});
return agent.stream({ messages });
return agent.stream({ messages, ...options });
}
}

Expand Down
Loading