Skip to content
12 changes: 11 additions & 1 deletion config/coding-agent-instructions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ You are a coding agent with the following tools:
- fs/read: read a file (relative path)
- fs/bash: run a shell command
- fs/glob: list files matching a pattern
- github/get_repo_info: discover which GitHub repository tickets will be created in
- github/create_issue: create a GitHub issue (requires title, optional: body, labels)
- github/search_issues: search for GitHub issues
- github/comment_issue: add a comment to a GitHub issue
- github/close_issue: close a GitHub issue

Only use tools when the task requires reading or writing files, or running shell commands.
When using GitHub tools:
1. If the user asks to create/search/manage issues, first call github/get_repo_info to understand the target repository.
2. For create_issue: always provide a title. Ask the user if they haven't specified what the issue should be about.
3. For other GitHub operations: be explicit about what action you're taking.

Only use tools when the task requires reading/writing files, running commands, or performing GitHub operations.
For general questions, answer directly without using tools.

Be direct. No commentary unless asked.
84 changes: 84 additions & 0 deletions packages/agents/agent-tool-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @file packages/agents/agent-tool-registry.ts
* @description Central, auditable agent-to-tools access policy.
*
* This is the single source of truth for which tools each agent can access.
* It is NOT embedded in tool manifests (tools are agent-agnostic).
* Instead, agents reference this registry when building their tool maps.
*
* For event-sourcing: this config is versioned and can be snapshotted
* to recreate deterministic agent behavior at any point in time.
*/

export type AgentType = 'support' | 'coding';

interface AgentToolPolicy {
description: string;
tools: string[]; // tool names that this agent can access
}

/**
* Central declaration of which tools each agent can access.
* Add new agents and update tool access here.
*/
export const AGENT_TOOL_POLICIES: Record<AgentType, AgentToolPolicy> = {
support: {
description: 'Support agent: handles customer inquiries, order lookups, issue tracking',
tools: [
// Smoke test
'smoke',

// GitHub operations
'github/get_repo_info',
'github/create_issue',
'github/search_issues',
'github/comment_issue',
'github/close_issue',
],
},

coding: {
description: 'Coding agent: handles file operations, shell commands, and code tasks',
tools: [
// Filesystem operations
'fs/read',
'fs/write',
'fs/bash',
'fs/glob',

// GitHub operations (available to both)
'github/get_repo_info',
'github/create_issue',
'github/search_issues',
'github/comment_issue',
'github/close_issue',
],
},
};

/**
* Get the list of tool names allowed for a given agent.
* @param agentType The agent type (e.g., 'support', 'coding')
* @returns Array of tool names this agent can access
*/
export function getAllowedToolsForAgent(agentType: AgentType): string[] {
const policy = AGENT_TOOL_POLICIES[agentType];
if (!policy) {
throw new Error(`Unknown agent type: ${agentType}`);
}
return policy.tools;
}

/**
* Filter a tool registry by agent type.
* @param tools Full list of available tools from registry
* @param agentType The agent type to filter for
* @returns Only the tools this agent is allowed to use
*/
export function filterToolsForAgent<T extends { name: string }>(
tools: T[],
agentType: AgentType
): T[] {
const allowedNames = getAllowedToolsForAgent(agentType);
return tools.filter((tool) => allowedNames.includes(tool.name));
}
46 changes: 46 additions & 0 deletions packages/agents/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test, expect } from 'bun:test';
import { supportAgent } from './support-agent';
import { runTool } from '@sup/tools';

const TEST_TIMEOUT = 30000;

function makeMockClient(returnText: string) {
return {
specificationVersion: 'v2' as const,
provider: 'test-provider',
modelId: 'mock-model',
doGenerate: async () => ({
text: returnText,
content: [{ type: 'text', text: returnText }],
finishReason: 'stop' as const,
usage: { promptTokens: 0, completionTokens: 0 },
rawCall: { rawPrompt: null, rawSettings: {} },
}),
} as any;
}

test('supportAgent has all tools available in registry including github/*', async () => {
const mockResponse = 'I cannot find issues.';
const mock = makeMockClient(mockResponse);
const session = { id: 'test-support-github', events: [] } as any;

const gen = supportAgent('Search for bug issues', session, { client: mock });

for await (const step of gen) {
// Just iterate through to confirm no errors
}

// If no error thrown, tools were registered successfully
expect(true).toBe(true);
}, TEST_TIMEOUT);

test('agent can discover repository configuration via github/get_repo_info tool', async () => {
process.env.REPO_OWNER = 'TestOrg';
process.env.REPO_NAME = 'test-repository';

const repoInfo = await runTool('github/get_repo_info', {});

expect(repoInfo.owner).toBe('TestOrg');
expect(repoInfo.repo).toBe('test-repository');
expect(repoInfo.fullName).toBe('TestOrg/test-repository');
}, TEST_TIMEOUT);
31 changes: 18 additions & 13 deletions packages/agents/coding-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { readFileSync } from 'fs';
import type { AgentSession, AgentEvent, AgentStep } from '@sup/types/types';
import { logger } from '@sup/infra/logger';
import { tools as registry, runTool } from '@sup/tools';
import { filterToolsForAgent } from './agent-tool-registry';

const ROUTER_MODEL = 'qwen3:8b';
const CODER_MODEL = process.env.CODING_AGENT_MODEL || 'qwen2.5-coder:14b';
Expand All @@ -26,8 +27,7 @@ const instructions = readFileSync(
'utf-8'
).replace('{CWD}', CWD);

const FS_TOOLS = ['fs/write', 'fs/read', 'fs/bash', 'fs/glob'];
const fsRegistry = registry.filter((t) => FS_TOOLS.includes(t.name));
const toolRegistry = filterToolsForAgent(registry, 'coding');

function toToolKey(name: string): string {
return name.replace('/', '_');
Expand All @@ -54,7 +54,7 @@ async function needsTools(userInput: string, history: string): Promise<boolean>
const response = await generateText({
model: ollama(ROUTER_MODEL),
temperature: 0,
system: `You are a router. Decide if the user's request requires reading or writing files, running shell commands, or executing code.
system: `You are a router. Decide if the user's request requires tool use: reading or writing files, running shell commands, executing code, creating/searching/updating GitHub issues, or other external actions.
Reply with only "yes" or "no".`,
prompt: history ? `${history}\nUser: ${userInput}` : userInput,
});
Expand Down Expand Up @@ -205,14 +205,18 @@ export async function* codingAgent(
logger.debug({ model: CODER_MODEL }, '[codingAgent] tool path');

const toolsMap = Object.fromEntries(
fsRegistry.map((t) => [
toToolKey(t.name),
{
description: t.description,
parameters: t.parameters,
execute: async (args: any) => runTool(t.name, args),
},
])
toolRegistry.map((t) => {
// Only transform FS tool names; keep others (like github/*) as-is
const toolKey = t.name.startsWith('fs/') ? toToolKey(t.name) : t.name;
return [
toolKey,
{
description: t.description,
parameters: t.parameters,
execute: async (args: any) => runTool(t.name, args),
},
];
})
);

logger.debug({ toolKeys: Object.keys(toolsMap) }, '[codingAgent] registered tools');
Expand Down Expand Up @@ -293,8 +297,9 @@ export async function* codingAgent(
try {
const parsed = JSON.parse(jsonMatch[0]);
const toolKey = parsed.name || parsed.tool || parsed.toolName;
const originalName = fromToolKey(toolKey);
const toolDef = fsRegistry.find((t) => t.name === originalName);
// Try both: if it looks like an FS tool (fs_*), convert back; otherwise use as-is
const originalName = toolKey.startsWith('fs_') ? fromToolKey(toolKey) : toolKey;
const toolDef = toolRegistry.find((t) => t.name === originalName);
const args = parsed.arguments ?? parsed.parameters ?? parsed.args;
logger.debug({ toolKey, originalName, hasToolDef: !!toolDef, args }, '[codingAgent] regex fallback parsed');

Expand Down
3 changes: 2 additions & 1 deletion packages/agents/support-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { rebuildGraph } from '@sup/lib/graph-reducer';
import { logger } from '@sup/infra/logger';
import { CONTEXT_ANCHOR } from '@sup/agents/config';
import { tools as registry, runTool } from "@sup/tools";
import { filterToolsForAgent } from "./agent-tool-registry";
// import { OutputPort } from '@sup/domain';

// const DEFAULT_MODEL = 'qwen2.5:7b'; // AGENT_MODEL
Expand Down Expand Up @@ -159,7 +160,7 @@ export async function* supportAgent(
system: systemPrompt,
temperature: supportAgentConfig.temperature,
tools: Object.fromEntries(
registry.map((t) => [
filterToolsForAgent(registry, 'support').map((t) => [
t.name,
{
description: t.description,
Expand Down
6 changes: 4 additions & 2 deletions packages/tools/github/create_issue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ export interface CreateIssueParams {
export async function createIssue(params: CreateIssueParams) {
const { title, body = "", labels = [] } = params;

if (!title) {
throw new Error("title parameter is required");
if (!title || title.trim() === '') {
throw new Error(
'title parameter is required. Please provide a brief title for the GitHub issue. Example: "Fix login bug" or "Add email validation"'
);
}

if (!token) {
Expand Down
18 changes: 14 additions & 4 deletions packages/tools/github/create_issue/manifest.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
{
"name": "github/create_issue",
"description": "Files a new issue in a GitHub repository.",
"description": "Create a new GitHub issue in the configured repository. REQUIRED: title. OPTIONAL: body (description), labels (tags for the issue).",
"entry": "./index.ts",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string" },
"body": { "type": "string" },
"labels": { "type": "array", "items": { "type": "string" } }
"title": {
"type": "string",
"description": "Brief title for the issue (required). Example: 'Fix login button styling' or 'Add email validation'"
},
"body": {
"type": "string",
"description": "Detailed description of the issue (optional). Include context, steps to reproduce, or desired behavior."
},
"labels": {
"type": "array",
"items": { "type": "string" },
"description": "Tags to categorize the issue (optional). Examples: 'bug', 'feature', 'docs', 'enhancement'"
}
},
"required": ["title"]
}
Expand Down
28 changes: 28 additions & 0 deletions packages/tools/github/get_repo_info/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test, expect } from 'bun:test';
import { runTool } from '../../loader';

test('runTool can get repository info', async () => {
process.env.REPO_OWNER = 'TestOwner';
process.env.REPO_NAME = 'test-repo';

const result = await runTool('github/get_repo_info', {});

expect(result).toEqual({
owner: 'TestOwner',
repo: 'test-repo',
fullName: 'TestOwner/test-repo',
});
});

test('get_repo_info fails gracefully when repo not configured', async () => {
delete process.env.REPO_OWNER;
delete process.env.REPO_NAME;

try {
await runTool('github/get_repo_info', {});
expect.unreachable('Should have thrown an error');
} catch (error) {
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toContain('Repository not configured');
}
});
35 changes: 35 additions & 0 deletions packages/tools/github/get_repo_info/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @file /packages/tools/github/get_repo_info/index.ts
* @description Get the current GitHub repository configuration that will be used for ticket operations.
*/

export interface RepositoryInfo {
owner: string;
repo: string;
fullName: string;
}

export async function getRepoInfo(): Promise<RepositoryInfo> {
const owner = process.env.REPO_OWNER;
const repo = process.env.REPO_NAME;

if (!owner || !repo) {
throw new Error(
`Repository not configured. Set REPO_OWNER and REPO_NAME environment variables.`
);
}

return {
owner,
repo,
fullName: `${owner}/${repo}`,
};
}

/**
* Agent entrypoint
* The agent runner calls this function with tool arguments.
*/
export async function run() {
return await getRepoInfo();
}
10 changes: 10 additions & 0 deletions packages/tools/github/get_repo_info/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "github/get_repo_info",
"description": "Get the GitHub repository (owner and name) that ticket operations will target. Use this to discover which repository the agent is configured to work with.",
"entry": "index.ts",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
2 changes: 1 addition & 1 deletion packages/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @file index.ts
* @description Central registry for all support tools.
* @description Central registry for all tools.
*/
import registry from "./registry.json" assert { type: "json" };
import { runTool } from "./loader";
Expand Down
25 changes: 25 additions & 0 deletions packages/tools/loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { test, expect } from 'bun:test';
import { runTool } from './loader';

test('runTool can load nested github tools from registry importPath', async () => {
process.env.GITHUB_TOKEN = 'fake-token';
process.env.REPO_OWNER = 'example';
process.env.REPO_NAME = 'repo';

const originalFetch = globalThis.fetch;

globalThis.fetch = async () => {
return {
ok: true,
json: async () => ({ total_count: 0, items: [] }),
text: async () => '[]'
} as any;
};

try {
const result = await runTool('github/search_issues', { query: 'bug', state: 'open' });
expect(result).toEqual({ total: 0, issues: [] });
} finally {
globalThis.fetch = originalFetch;
}
});
Loading
Loading