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:
parent
58f5ebca68
commit
7dd39f65f6
16
agent.json
Normal file
16
agent.json
Normal 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": []
|
||||
}
|
||||
@ -11,7 +11,7 @@ import {
|
||||
formatSkillsForPrompt,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
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 { logger } from './logger.js';
|
||||
|
||||
@ -27,6 +27,8 @@ export interface AgentOptions {
|
||||
sessionDir?: string;
|
||||
/** Restrict file access to these directories. Empty/undefined = unrestricted. */
|
||||
allowedPaths?: string[];
|
||||
/** Additional custom tools (e.g. tracker tools) */
|
||||
customTools?: ToolDefinition[];
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
@ -158,7 +160,7 @@ export async function* runAgent(
|
||||
model,
|
||||
thinkingLevel: 'off',
|
||||
tools,
|
||||
customTools: [],
|
||||
customTools: options.customTools || [],
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
}));
|
||||
|
||||
@ -11,7 +11,7 @@ import { EventRouter } from './router.js';
|
||||
import type { IncomingMessage } from './transport/types.js';
|
||||
|
||||
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 http = new HttpTransport(config.listenPort);
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ import path from 'path';
|
||||
import { logger } from './logger.js';
|
||||
import { runAgent } from './agent.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 { TrackerEvent, TrackerTask } from './tracker/types.js';
|
||||
|
||||
@ -13,12 +15,19 @@ export interface TaskTracker {
|
||||
export class EventRouter {
|
||||
private log = logger.child({ component: 'event-router' });
|
||||
private activeTasks = 0;
|
||||
private trackerTools: ToolDefinition[];
|
||||
|
||||
constructor(
|
||||
private config: AgentConfig,
|
||||
private client: TrackerClient,
|
||||
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> {
|
||||
this.log.info('┌── ROUTER: handling %s (id: %s)', event.event, event.id);
|
||||
@ -84,6 +93,7 @@ export class EventRouter {
|
||||
skillsDir: this.config.agentHome,
|
||||
sessionDir: path.join(this.config.agentHome, 'sessions'),
|
||||
allowedPaths: this.config.allowedPaths,
|
||||
customTools: this.trackerTools,
|
||||
})) {
|
||||
if (msg.type === 'text') {
|
||||
collectedText += msg.content;
|
||||
@ -154,6 +164,7 @@ export class EventRouter {
|
||||
skillsDir: this.config.agentHome,
|
||||
sessionDir: path.join(this.config.agentHome, 'sessions'),
|
||||
allowedPaths: this.config.allowedPaths,
|
||||
customTools: this.trackerTools,
|
||||
})) {
|
||||
if (msg.type === 'text') {
|
||||
collectedText += msg.content;
|
||||
|
||||
23
src/tools/index.ts
Normal file
23
src/tools/index.ts
Normal 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
22
src/tools/members.ts
Normal 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
49
src/tools/messages.ts
Normal 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
36
src/tools/projects.ts
Normal 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
42
src/tools/steps.ts
Normal 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
128
src/tools/tasks.ts
Normal 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
7
src/tools/types.ts
Normal 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;
|
||||
}
|
||||
@ -1,71 +1,30 @@
|
||||
import { logger } from '../logger.js';
|
||||
import { TrackerClient } from './client.js';
|
||||
import type { AgentConfig } from '../config.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 {
|
||||
private log = logger.child({ component: 'tracker-registration' });
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private currentTasks = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private client: TrackerClient,
|
||||
private config: AgentConfig,
|
||||
) {}
|
||||
|
||||
private callbackUrl = '';
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
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);
|
||||
}
|
||||
async register(_callbackUrl: string): Promise<void> {
|
||||
this.log.warn('HTTP registration not supported — use WS transport instead');
|
||||
}
|
||||
|
||||
startHeartbeat(): void {
|
||||
const intervalMs = this.config.heartbeatIntervalSec * 1000;
|
||||
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');
|
||||
this.log.warn('HTTP heartbeat not supported — use WS transport instead');
|
||||
}
|
||||
|
||||
stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
this.log.info('Heartbeat stopped');
|
||||
// no-op
|
||||
}
|
||||
|
||||
addTask(taskId: string): void {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user