feat: MCP-compatible tracker tools (Function Calling hybrid)

- 14 tools: tasks (7), steps (2), messages (2), projects (2), members (1)
- TypeBox schemas for parameter validation
- Injected via customTools into Pi Agent Core session
- Tools wrap TrackerClient REST methods
This commit is contained in:
Markov 2026-02-23 21:51:02 +01:00
parent 58f5ebca68
commit 7dd39f65f6
12 changed files with 349 additions and 54 deletions

16
agent.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "Кодер",
"slug": "coder",
"prompt": "Ты опытный разработчик. Пишешь чистый, идиоматичный код. Отвечаешь кратко и по делу. Язык общения — русский.",
"tracker_url": "http://localhost:8100",
"ws_url": "ws://localhost:8100/ws",
"token": "tb-44425628cf52a7541051ba82550ef92fe6e0797d37770233a4f9b913de1f56a9",
"transport": "ws",
"work_dir": "/root/projects/team-board",
"model": "sonnet",
"provider": "anthropic",
"capabilities": ["coding", "review"],
"max_concurrent_tasks": 2,
"heartbeat_interval_sec": 30,
"allowed_paths": []
}

View File

@ -11,7 +11,7 @@ import {
formatSkillsForPrompt, formatSkillsForPrompt,
} from '@mariozechner/pi-coding-agent'; } from '@mariozechner/pi-coding-agent';
import { createSandboxedTools } from './sandbox.js'; import { createSandboxedTools } from './sandbox.js';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { AgentSessionEvent, ToolDefinition } from '@mariozechner/pi-coding-agent';
import { streamSimple } from '@mariozechner/pi-ai'; import { streamSimple } from '@mariozechner/pi-ai';
import { logger } from './logger.js'; import { logger } from './logger.js';
@ -27,6 +27,8 @@ export interface AgentOptions {
sessionDir?: string; sessionDir?: string;
/** Restrict file access to these directories. Empty/undefined = unrestricted. */ /** Restrict file access to these directories. Empty/undefined = unrestricted. */
allowedPaths?: string[]; allowedPaths?: string[];
/** Additional custom tools (e.g. tracker tools) */
customTools?: ToolDefinition[];
} }
export interface AgentMessage { export interface AgentMessage {
@ -158,7 +160,7 @@ export async function* runAgent(
model, model,
thinkingLevel: 'off', thinkingLevel: 'off',
tools, tools,
customTools: [], customTools: options.customTools || [],
sessionManager, sessionManager,
settingsManager, settingsManager,
})); }));

View File

@ -11,7 +11,7 @@ import { EventRouter } from './router.js';
import type { IncomingMessage } from './transport/types.js'; import type { IncomingMessage } from './transport/types.js';
async function startAgentHttp(config: AgentConfig, client: TrackerClient): Promise<void> { async function startAgentHttp(config: AgentConfig, client: TrackerClient): Promise<void> {
const registration = new TrackerRegistration(client, config); const registration = new TrackerRegistration(config);
const router = new EventRouter(config, client, registration); const router = new EventRouter(config, client, registration);
const http = new HttpTransport(config.listenPort); const http = new HttpTransport(config.listenPort);

View File

@ -2,6 +2,8 @@ import path from 'path';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { runAgent } from './agent.js'; import { runAgent } from './agent.js';
import { TrackerClient } from './tracker/client.js'; import { TrackerClient } from './tracker/client.js';
import { createTrackerTools } from './tools/index.js';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { AgentConfig } from './config.js'; import type { AgentConfig } from './config.js';
import type { TrackerEvent, TrackerTask } from './tracker/types.js'; import type { TrackerEvent, TrackerTask } from './tracker/types.js';
@ -13,12 +15,19 @@ export interface TaskTracker {
export class EventRouter { export class EventRouter {
private log = logger.child({ component: 'event-router' }); private log = logger.child({ component: 'event-router' });
private activeTasks = 0; private activeTasks = 0;
private trackerTools: ToolDefinition[];
constructor( constructor(
private config: AgentConfig, private config: AgentConfig,
private client: TrackerClient, private client: TrackerClient,
private taskTracker: TaskTracker, private taskTracker: TaskTracker,
) {} ) {
this.trackerTools = createTrackerTools({
trackerClient: client,
agentSlug: config.slug,
});
this.log.info({ toolCount: this.trackerTools.length }, 'Tracker tools registered');
}
async handleEvent(event: TrackerEvent): Promise<void> { async handleEvent(event: TrackerEvent): Promise<void> {
this.log.info('┌── ROUTER: handling %s (id: %s)', event.event, event.id); this.log.info('┌── ROUTER: handling %s (id: %s)', event.event, event.id);
@ -84,6 +93,7 @@ export class EventRouter {
skillsDir: this.config.agentHome, skillsDir: this.config.agentHome,
sessionDir: path.join(this.config.agentHome, 'sessions'), sessionDir: path.join(this.config.agentHome, 'sessions'),
allowedPaths: this.config.allowedPaths, allowedPaths: this.config.allowedPaths,
customTools: this.trackerTools,
})) { })) {
if (msg.type === 'text') { if (msg.type === 'text') {
collectedText += msg.content; collectedText += msg.content;
@ -154,6 +164,7 @@ export class EventRouter {
skillsDir: this.config.agentHome, skillsDir: this.config.agentHome,
sessionDir: path.join(this.config.agentHome, 'sessions'), sessionDir: path.join(this.config.agentHome, 'sessions'),
allowedPaths: this.config.allowedPaths, allowedPaths: this.config.allowedPaths,
customTools: this.trackerTools,
})) { })) {
if (msg.type === 'text') { if (msg.type === 'text') {
collectedText += msg.content; collectedText += msg.content;

23
src/tools/index.ts Normal file
View File

@ -0,0 +1,23 @@
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
import { createTaskTools } from './tasks.js';
import { createStepTools } from './steps.js';
import { createMessageTools } from './messages.js';
import { createProjectTools } from './projects.js';
import { createMemberTools } from './members.js';
/**
* Create all Team Board tracker tools for the agent.
* These are passed as `customTools` to createAgentSession().
*/
export function createTrackerTools(ctx: ToolContext): ToolDefinition[] {
return [
...createTaskTools(ctx),
...createStepTools(ctx),
...createMessageTools(ctx),
...createProjectTools(ctx),
...createMemberTools(ctx),
];
}
export type { ToolContext } from './types.js';

22
src/tools/members.ts Normal file
View File

@ -0,0 +1,22 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
function ok(text: string) {
return { content: [{ type: 'text' as const, text }], details: {} };
}
export function createMemberTools(ctx: ToolContext): ToolDefinition<any>[] {
return [
{
name: 'list_members',
label: 'List Members',
description: 'List all team members (humans and agents) with their slugs, roles, and online status.',
parameters: Type.Object({}),
async execute() {
const members = await ctx.trackerClient.listMembers();
return ok(JSON.stringify(members, null, 2));
},
},
];
}

49
src/tools/messages.ts Normal file
View File

@ -0,0 +1,49 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
const SendMessageParams = Type.Object({
content: Type.String({ description: 'Message text (markdown supported)' }),
chat_id: Type.Optional(Type.String({ description: 'Chat UUID (for chat messages)' })),
task_id: Type.Optional(Type.String({ description: 'Task UUID (for task comments)' })),
mentions: Type.Optional(Type.Array(Type.String(), { description: 'Slugs to mention' })),
});
const ListMessagesParams = Type.Object({
chat_id: Type.Optional(Type.String({ description: 'Chat UUID' })),
task_id: Type.Optional(Type.String({ description: 'Task UUID' })),
limit: Type.Optional(Type.Number({ description: 'Max messages to return (default 50)' })),
});
function ok(text: string) {
return { content: [{ type: 'text' as const, text }], details: {} };
}
export function createMessageTools(ctx: ToolContext): ToolDefinition<any>[] {
return [
{
name: 'send_message',
label: 'Send Message',
description: 'Send a message to a chat or add a comment to a task. Specify either chat_id or task_id.',
parameters: SendMessageParams,
async execute(_id: string, params: any) {
const msg = await ctx.trackerClient.sendMessage(params, ctx.agentSlug);
return ok(JSON.stringify(msg, null, 2));
},
},
{
name: 'list_messages',
label: 'List Messages',
description: 'Get messages from a chat or task comments. Specify chat_id or task_id.',
parameters: ListMessagesParams,
async execute(_id: string, params: any) {
const query: Record<string, string> = {};
if (params.chat_id) query.chat_id = params.chat_id;
if (params.task_id) query.task_id = params.task_id;
if (params.limit) query.limit = String(params.limit);
const messages = await ctx.trackerClient.listMessages(query);
return ok(JSON.stringify(messages, null, 2));
},
},
];
}

36
src/tools/projects.ts Normal file
View File

@ -0,0 +1,36 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
const GetProjectParams = Type.Object({
slug: Type.String({ description: 'Project slug' }),
});
function ok(text: string) {
return { content: [{ type: 'text' as const, text }], details: {} };
}
export function createProjectTools(ctx: ToolContext): ToolDefinition<any>[] {
return [
{
name: 'list_projects',
label: 'List Projects',
description: 'List all projects the agent has access to.',
parameters: Type.Object({}),
async execute() {
const projects = await ctx.trackerClient.listProjects();
return ok(JSON.stringify(projects, null, 2));
},
},
{
name: 'get_project',
label: 'Get Project',
description: 'Get project details by slug, including chat_id.',
parameters: GetProjectParams,
async execute(_id: string, params: any) {
const project = await ctx.trackerClient.getProject(params.slug);
return ok(JSON.stringify(project, null, 2));
},
},
];
}

42
src/tools/steps.ts Normal file
View File

@ -0,0 +1,42 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
const AddStepParams = Type.Object({
task_id: Type.String({ description: 'Task UUID' }),
title: Type.String({ description: 'Step title' }),
});
const CompleteStepParams = Type.Object({
task_id: Type.String({ description: 'Task UUID' }),
step_id: Type.String({ description: 'Step UUID' }),
});
function ok(text: string) {
return { content: [{ type: 'text' as const, text }], details: {} };
}
export function createStepTools(ctx: ToolContext): ToolDefinition<any>[] {
return [
{
name: 'add_step',
label: 'Add Step',
description: 'Add a checklist step to a task.',
parameters: AddStepParams,
async execute(_id: string, params: any) {
const step = await ctx.trackerClient.addStep(params.task_id, params.title);
return ok(JSON.stringify(step, null, 2));
},
},
{
name: 'complete_step',
label: 'Complete Step',
description: 'Mark a checklist step as done.',
parameters: CompleteStepParams,
async execute(_id: string, params: any) {
await ctx.trackerClient.completeStep(params.task_id, params.step_id);
return ok(`Step ${params.step_id} completed`);
},
},
];
}

128
src/tools/tasks.ts Normal file
View File

@ -0,0 +1,128 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
const ListTasksParams = Type.Object({
project_slug: Type.Optional(Type.String({ description: 'Filter by project slug' })),
status: Type.Optional(Type.String({ description: 'Filter by status: backlog|todo|in_progress|in_review|done' })),
assignee_slug: Type.Optional(Type.String({ description: 'Filter by assignee slug' })),
});
const GetTaskParams = Type.Object({
task_id: Type.String({ description: 'Task UUID' }),
});
const CreateTaskParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
title: Type.String({ description: 'Task title' }),
description: Type.Optional(Type.String({ description: 'Task description (markdown)' })),
priority: Type.Optional(Type.String({ description: 'Priority: low|medium|high|critical' })),
labels: Type.Optional(Type.Array(Type.String(), { description: 'Labels array' })),
});
const UpdateTaskParams = Type.Object({
task_id: Type.String({ description: 'Task UUID' }),
title: Type.Optional(Type.String({ description: 'New title' })),
description: Type.Optional(Type.String({ description: 'New description' })),
status: Type.Optional(Type.String({ description: 'New status: backlog|todo|in_progress|in_review|done' })),
priority: Type.Optional(Type.String({ description: 'New priority: low|medium|high|critical' })),
assignee_slug: Type.Optional(Type.String({ description: 'Assignee slug (or null to unassign)' })),
});
const TakeTaskParams = Type.Object({
task_id: Type.String({ description: 'Task UUID to take' }),
});
const RejectTaskParams = Type.Object({
task_id: Type.String({ description: 'Task UUID to reject' }),
reason: Type.String({ description: 'Rejection reason' }),
});
const WatchTaskParams = Type.Object({
task_id: Type.String({ description: 'Task UUID to watch' }),
});
function ok(text: string) {
return { content: [{ type: 'text' as const, text }], details: {} };
}
export function createTaskTools(ctx: ToolContext): ToolDefinition<any>[] {
return [
{
name: 'list_tasks',
label: 'List Tasks',
description: 'List tasks with optional filters (project_slug, status, assignee_slug). Returns array of task objects.',
parameters: ListTasksParams,
async execute(_id: string, params: any) {
const query: Record<string, string> = {};
if (params.project_slug) query.project_slug = params.project_slug;
if (params.status) query.status = params.status;
if (params.assignee_slug) query.assignee_slug = params.assignee_slug;
const tasks = await ctx.trackerClient.listTasks(query);
return ok(JSON.stringify(tasks, null, 2));
},
},
{
name: 'get_task',
label: 'Get Task',
description: 'Get task details by UUID. Returns full task object with steps, comments, watchers.',
parameters: GetTaskParams,
async execute(_id: string, params: any) {
const task = await ctx.trackerClient.getTask(params.task_id);
return ok(JSON.stringify(task, null, 2));
},
},
{
name: 'create_task',
label: 'Create Task',
description: 'Create a new task in a project. Returns the created task with generated key (e.g. TE-1).',
parameters: CreateTaskParams,
async execute(_id: string, params: any) {
const { project_slug, ...taskData } = params;
const task = await ctx.trackerClient.createTask(project_slug, taskData);
return ok(JSON.stringify(task, null, 2));
},
},
{
name: 'update_task',
label: 'Update Task',
description: 'Update task fields (title, description, status, priority, assignee_slug). Only pass fields you want to change.',
parameters: UpdateTaskParams,
async execute(_id: string, params: any) {
const { task_id, ...fields } = params;
await ctx.trackerClient.updateTask(task_id, fields);
return ok(`Task ${task_id} updated`);
},
},
{
name: 'take_task',
label: 'Take Task',
description: 'Take a task for yourself (atomically assign to this agent). Task must be in backlog or todo status.',
parameters: TakeTaskParams,
async execute(_id: string, params: any) {
await ctx.trackerClient.takeTask(params.task_id, ctx.agentSlug);
return ok(`Task ${params.task_id} taken by ${ctx.agentSlug}`);
},
},
{
name: 'reject_task',
label: 'Reject Task',
description: 'Reject an assigned task with a reason. Task returns to backlog.',
parameters: RejectTaskParams,
async execute(_id: string, params: any) {
await ctx.trackerClient.rejectTask(params.task_id, ctx.agentSlug, params.reason);
return ok(`Task ${params.task_id} rejected: ${params.reason}`);
},
},
{
name: 'watch_task',
label: 'Watch Task',
description: 'Subscribe to task notifications (become a watcher).',
parameters: WatchTaskParams,
async execute(_id: string, params: any) {
await ctx.trackerClient.watchTask(params.task_id, ctx.agentSlug);
return ok(`Now watching task ${params.task_id}`);
},
},
];
}

7
src/tools/types.ts Normal file
View File

@ -0,0 +1,7 @@
import type { TrackerClient } from '../tracker/client.js';
/** Shared context passed to all tool handlers. */
export interface ToolContext {
trackerClient: TrackerClient;
agentSlug: string;
}

View File

@ -1,71 +1,30 @@
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { TrackerClient } from './client.js';
import type { AgentConfig } from '../config.js'; import type { AgentConfig } from '../config.js';
import type { TaskTracker } from '../router.js'; import type { TaskTracker } from '../router.js';
/**
* HTTP transport registration (legacy).
* For WS transport, registration happens via auth message.
* This class is kept for HTTP callback mode compatibility.
*/
export class TrackerRegistration implements TaskTracker { export class TrackerRegistration implements TaskTracker {
private log = logger.child({ component: 'tracker-registration' }); private log = logger.child({ component: 'tracker-registration' });
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private currentTasks = new Set<string>(); private currentTasks = new Set<string>();
constructor( constructor(
private client: TrackerClient,
private config: AgentConfig, private config: AgentConfig,
) {} ) {}
private callbackUrl = ''; async register(_callbackUrl: string): Promise<void> {
private retryTimer: ReturnType<typeof setTimeout> | null = null; this.log.warn('HTTP registration not supported — use WS transport instead');
private registered = false;
async register(callbackUrl: string): Promise<void> {
this.callbackUrl = callbackUrl;
await this.tryRegister();
}
private async tryRegister(): Promise<void> {
try {
this.log.info({ callbackUrl: this.callbackUrl }, 'Registering with tracker');
await this.client.register({
name: this.config.name,
slug: this.config.slug,
capabilities: this.config.capabilities,
max_concurrent_tasks: this.config.maxConcurrentTasks,
callback_url: this.callbackUrl,
});
this.registered = true;
this.log.info('Registration successful');
} catch (err) {
this.registered = false;
this.log.warn({ err }, 'Registration failed, retrying in 5s');
this.retryTimer = setTimeout(() => this.tryRegister(), 5000);
}
} }
startHeartbeat(): void { startHeartbeat(): void {
const intervalMs = this.config.heartbeatIntervalSec * 1000; this.log.warn('HTTP heartbeat not supported — use WS transport instead');
this.heartbeatTimer = setInterval(async () => {
try {
await this.client.heartbeat({
status: this.currentTasks.size > 0 ? 'busy' : 'idle',
current_tasks: [...this.currentTasks],
});
} catch (err) {
this.log.warn({ err }, 'Heartbeat failed');
}
}, intervalMs);
this.log.info({ intervalSec: this.config.heartbeatIntervalSec }, 'Heartbeat started');
} }
stopHeartbeat(): void { stopHeartbeat(): void {
if (this.heartbeatTimer) { // no-op
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.log.info('Heartbeat stopped');
} }
addTask(taskId: string): void { addTask(taskId: string): void {