Skip to content

Commit 734981d

Browse files
committed
feat: expand agent integrations and GPU backends
1 parent 105db65 commit 734981d

12 files changed

Lines changed: 759 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
6161
**Models & hardware**
6262
- Model recommendations based on your actual hardware — RAM and VRAM are detected and summed **across all GPUs**, not just the first one, so multi-GPU machines get accurate suggestions.
6363
- **GPU offload control** — set how many model layers Ollama offloads to GPU (`num_gpu`) per chat, per project, or as a global default; leave it blank to let Ollama decide automatically.
64+
- **Custom local GPU backends** — register any OpenAI-compatible local endpoint as a no-key GPU backend, including vLLM, LocalAI, TGI, vendor runtimes, and custom CUDA/ROCm/SYCL/Vulkan llama-server builds. Models from these endpoints appear beside other custom providers and retain streaming and Agent tool-calling support when the server supports it.
65+
- **Bounded VRAM cache** — llama.cpp model loads are coalesced, active models are protected, and idle model/offload variants are evicted least-recently-used so switching models does not grow VRAM use without limit.
6466
- **Real Hugging Face search** — typing in the model search box queries the actual Hugging Face Hub API (not just "paste an exact URL"), showing real repos ranked by downloads/likes; expand one to see its actual GGUF files with real file sizes, then either pull it via Ollama or download it directly for the llama.cpp backend. Pasting an exact `hf.co/user/repo` tag or a full URL still works too, for Ollama's own pull mechanism.
6567
- Models with reliable tool/function-calling support are flagged with a 🔧 badge, so picking a good Agent mode model doesn't require guesswork.
6668
- **Custom model storage location** — Settings → General → Ollama Server → "Model storage location" lets you point downloaded models at any folder (e.g. a larger or faster drive) instead of Ollama's default location. If this app started Ollama, it restarts it automatically with the new location; if Ollama is running outside the app, you're told to restart it yourself.
@@ -142,19 +144,25 @@ Click **Agent** in the chat toolbar and pick a folder — that becomes the model
142144

143145
| Tool | What it does |
144146
|---|---|
145-
| `read_file` | Read a text file in the workspace |
147+
| `read_file` | Read a text file, optionally by line range |
146148
| `write_file` | Create or overwrite a file (creates parent directories as needed) |
149+
| `replace_in_file` | Replace an exact text block without rewriting the whole file; supports Undo |
150+
| `find_files`, `file_info` | Discover files by glob and inspect path metadata |
147151
| `list_dir` | List files and subdirectories |
148152
| `search_files` | Search for a text string across the workspace |
153+
| `make_directory`, `move_path`, `delete_path` | Organize workspace files with explicit approval for mutations |
149154
| `run_command` | Execute a shell command in the workspace (or a subfolder), with a 60s timeout |
150155
| `run_code` | Run a Python or JavaScript snippet — a convenience over shell-quoting multi-line code through `run_command`, not a new capability |
151156
| `git_status`, `git_diff`, `git_log` | Read-only git helpers (auto-approvable, like the file tools) so the model doesn't need to guess flag syntax |
152157
| `git_commit` | Stage everything and commit — requires approval, like `write_file` |
158+
| `github_list_repositories` | List repositories accessible to the linked GitHub account |
159+
| `github_repository_tree` | Inspect a repository's complete file structure before analysis |
160+
| `github_read_file` | Read selected files from public or private linked-account repositories |
153161

154162
**Safety model:**
155-
- `read_file`, `write_file`, `list_dir`, and `search_files` are genuinely confined to the chosen workspace folder — path-traversal attempts (`../../etc`, absolute paths elsewhere on disk) are rejected before anything runs.
163+
- Built-in filesystem tools are genuinely confined to the chosen workspace folder — path traversal (`../../etc`), absolute paths elsewhere on disk, and symlinks that resolve outside the workspace are rejected before anything runs.
156164
- `run_command` and `run_code` are different: a shell command (or a script `run_code` hands to `python3`/`node`) is opaque text that can reference any path on the system regardless of its working directory, so neither is sandboxed the way the file tools are. As a safety net, commands (and `run_code`'s source text) matching destructive or system-level patterns — deleting outside the workspace, formatting a drive, shutting down the machine, registry deletion, `sudo`/`runas`, piping a remote script into a shell — are **rejected outright**, even if already approved. This blocklist catches the common catastrophic cases, not everything a shell or script can do — only approve a command or snippet you actually understand.
157-
- Every call (including ones the blocklist doesn't catch) shows an **Allow / Deny** card before it executes — nothing runs without an explicit click. Read-only tools (`read_file`, `list_dir`, `search_files`, `git_status`, `git_diff`, `git_log`) can be marked "always allow this session" to cut down on repetitive approvals; `write_file`, `run_command`, `run_code`, and `git_commit` always require a fresh click, since they have real, potentially irreversible effects.
165+
- Every call (including ones the blocklist doesn't catch) shows an **Allow / Deny** card before it executes — nothing runs without an explicit click. Read-only tools (`read_file`, `find_files`, `file_info`, `list_dir`, `search_files`, `git_status`, `git_diff`, `git_log`) can be marked "always allow this session" to cut down on repetitive approvals; filesystem mutations, `run_command`, `run_code`, and `git_commit` always require a fresh click, since they have real, potentially irreversible effects.
158166
- A per-turn step limit (25 tool-result → model-continuation round trips) stops a model from looping indefinitely without producing a final answer.
159167
- The trust list for "always allow" is in-memory only — closing and reopening a chat resets it.
160168

‎app/src/accounts.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as secretsStore from "./secrets-store";
2+
3+
export type AccountProvider = "github" | "huggingface";
4+
5+
export interface LinkedAccount {
6+
provider: AccountProvider;
7+
username: string;
8+
displayName: string | null;
9+
avatarUrl: string | null;
10+
profileUrl: string;
11+
}
12+
13+
const TOKEN_KEYS: Record<AccountProvider, string> = {
14+
github: "github_token",
15+
huggingface: "huggingface_token",
16+
};
17+
18+
function assertProvider(provider: AccountProvider): void {
19+
if (provider !== "github" && provider !== "huggingface") throw new Error("Unsupported account provider.");
20+
}
21+
22+
async function requestProfile(provider: AccountProvider, token: string): Promise<LinkedAccount> {
23+
const url = provider === "github" ? "https://api.github.com/user" : "https://huggingface.co/api/whoami-v2";
24+
const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: "application/json" };
25+
if (provider === "github") {
26+
headers["X-GitHub-Api-Version"] = "2026-03-10";
27+
headers["User-Agent"] = "Modelforge";
28+
}
29+
let response: Response;
30+
try {
31+
response = await fetch(url, { headers });
32+
} catch (error) {
33+
throw new Error(`Couldn't reach ${provider === "github" ? "GitHub" : "Hugging Face"}: ${(error as Error).message}`);
34+
}
35+
if (response.status === 401) throw new Error("The token is invalid or has expired.");
36+
if (!response.ok) throw new Error(`Account verification failed (HTTP ${response.status}).`);
37+
const data = await response.json() as Record<string, unknown>;
38+
const username = String(provider === "github" ? data.login ?? "" : data.name ?? data.username ?? "");
39+
if (!username) throw new Error("The account provider returned an incomplete profile.");
40+
return {
41+
provider,
42+
username,
43+
displayName: typeof data.fullname === "string" ? data.fullname : typeof data.name === "string" && provider === "github" ? data.name : null,
44+
avatarUrl: typeof data.avatar_url === "string" ? data.avatar_url : typeof data.avatarUrl === "string" ? data.avatarUrl : null,
45+
profileUrl: provider === "github" ? `https://github.com/${username}` : `https://huggingface.co/${username}`,
46+
};
47+
}
48+
49+
export async function connectAccount(provider: AccountProvider, token: string): Promise<LinkedAccount> {
50+
assertProvider(provider);
51+
const trimmed = token.trim();
52+
if (!trimmed) throw new Error("Enter an access token.");
53+
const profile = await requestProfile(provider, trimmed);
54+
secretsStore.setSecret(TOKEN_KEYS[provider], trimmed);
55+
secretsStore.setSecret(`${TOKEN_KEYS[provider]}_profile`, JSON.stringify(profile));
56+
return profile;
57+
}
58+
59+
export function getLinkedAccount(provider: AccountProvider): LinkedAccount | null {
60+
assertProvider(provider);
61+
if (!secretsStore.hasSecret(TOKEN_KEYS[provider])) return null;
62+
const stored = secretsStore.getSecret(`${TOKEN_KEYS[provider]}_profile`);
63+
if (!stored) return null;
64+
try { return JSON.parse(stored) as LinkedAccount; } catch { return null; }
65+
}
66+
67+
export function disconnectAccount(provider: AccountProvider): void {
68+
assertProvider(provider);
69+
secretsStore.setSecret(TOKEN_KEYS[provider], "");
70+
secretsStore.setSecret(`${TOKEN_KEYS[provider]}_profile`, "");
71+
}
72+
73+
export function getAccountToken(provider: AccountProvider): string | null {
74+
assertProvider(provider);
75+
return secretsStore.getSecret(TOKEN_KEYS[provider]);
76+
}

‎app/src/agent-tools.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { execSync } from "node:child_process";
66
import {
77
readFile,
88
writeFile,
9+
replaceInFile,
10+
findFiles,
11+
fileInfo,
12+
makeDirectory,
13+
movePath,
14+
deletePath,
915
listDir,
1016
searchFiles,
1117
executeTool,
@@ -49,6 +55,13 @@ describe("agent-tools", () => {
4955
fs.writeFileSync(path.join(workspace, "sub", "file.txt"), "hi");
5056
expect(readFile(workspace, "sub/file.txt")).toBe("hi");
5157
});
58+
59+
it.skipIf(process.platform === "win32")("rejects a symlink that escapes the workspace", () => {
60+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "agent-tools-outside-"));
61+
fs.writeFileSync(path.join(outside, "secret.txt"), "secret");
62+
fs.symlinkSync(outside, path.join(workspace, "escape"), "dir");
63+
expect(() => readFile(workspace, "escape/secret.txt")).toThrow(/symbolic link/);
64+
});
5265
});
5366

5467
describe("readFile", () => {
@@ -68,6 +81,12 @@ describe("agent-tools", () => {
6881
expect(result.length).toBeLessThan(200_000);
6982
expect(result).toContain("truncated");
7083
});
84+
85+
it("reads an inclusive line range from a large file", () => {
86+
fs.writeFileSync(path.join(workspace, "lines.txt"), "one\ntwo\nthree\nfour");
87+
expect(readFile(workspace, "lines.txt", 2, 3)).toBe("two\nthree");
88+
expect(() => readFile(workspace, "lines.txt", 99, 100)).toThrow(/beyond the file/);
89+
});
7190
});
7291

7392
describe("writeFile", () => {
@@ -89,6 +108,45 @@ describe("agent-tools", () => {
89108
});
90109
});
91110

111+
describe("precise filesystem tools", () => {
112+
it("replaces a unique text block and supports rollback", () => {
113+
fs.writeFileSync(path.join(workspace, "edit.txt"), "before middle after");
114+
expect(replaceInFile(workspace, "edit.txt", "middle", "updated")).toMatchObject({ replacements: 1 });
115+
expect(readFile(workspace, "edit.txt")).toBe("before updated after");
116+
rollbackLastWrite(workspace);
117+
expect(readFile(workspace, "edit.txt")).toBe("before middle after");
118+
});
119+
120+
it("rejects ambiguous replacements unless replaceAll is enabled", () => {
121+
fs.writeFileSync(path.join(workspace, "edit.txt"), "same same");
122+
expect(() => replaceInFile(workspace, "edit.txt", "same", "new")).toThrow(/matched 2 times/);
123+
expect(replaceInFile(workspace, "edit.txt", "same", "new", true).replacements).toBe(2);
124+
});
125+
126+
it("finds files with recursive glob patterns and skips dependencies", () => {
127+
fs.mkdirSync(path.join(workspace, "src", "nested"), { recursive: true });
128+
fs.writeFileSync(path.join(workspace, "root.ts"), "");
129+
fs.writeFileSync(path.join(workspace, "src", "nested", "child.ts"), "");
130+
fs.mkdirSync(path.join(workspace, "node_modules"));
131+
fs.writeFileSync(path.join(workspace, "node_modules", "ignored.ts"), "");
132+
expect(findFiles(workspace, "**/*.ts")).toEqual(["root.ts", "src/nested/child.ts"]);
133+
});
134+
135+
it("reports metadata and creates, moves, then deletes workspace paths", () => {
136+
expect(makeDirectory(workspace, "a/b")).toEqual({ created: true });
137+
fs.writeFileSync(path.join(workspace, "a", "b", "file.txt"), "hello");
138+
expect(fileInfo(workspace, "a/b/file.txt")).toMatchObject({ type: "file", sizeBytes: 5 });
139+
expect(movePath(workspace, "a/b/file.txt", "renamed/file.txt")).toEqual({ moved: true });
140+
expect(deletePath(workspace, "renamed/file.txt")).toEqual({ deleted: true });
141+
expect(fs.existsSync(path.join(workspace, "renamed", "file.txt"))).toBe(false);
142+
});
143+
144+
it("never deletes or moves the workspace root", () => {
145+
expect(() => deletePath(workspace, ".", true)).toThrow(/workspace root/);
146+
expect(() => movePath(workspace, ".", "moved")).toThrow(/workspace root/);
147+
});
148+
});
149+
92150
describe("rollbackLastWrite", () => {
93151
it("returns null when there is nothing to roll back", () => {
94152
expect(rollbackLastWrite(workspace)).toBeNull();

0 commit comments

Comments
 (0)