diff --git a/agent.json b/agent.json new file mode 100644 index 0000000..42ca420 --- /dev/null +++ b/agent.json @@ -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": [] +} diff --git a/src/agent.ts b/src/agent.ts index 6b0b8d4..079d719 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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, })); diff --git a/src/index.ts b/src/index.ts index f209296..264c135 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { EventRouter } from './router.js'; import type { IncomingMessage } from './transport/types.js'; async function startAgentHttp(config: AgentConfig, client: TrackerClient): Promise { - const registration = new TrackerRegistration(client, config); + const registration = new TrackerRegistration(config); const router = new EventRouter(config, client, registration); const http = new HttpTransport(config.listenPort); diff --git a/src/router.ts b/src/router.ts index bb856a8..6ff75b9 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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 { 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; diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..e0836e0 --- /dev/null +++ b/src/tools/index.ts @@ -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'; diff --git a/src/tools/members.ts b/src/tools/members.ts new file mode 100644 index 0000000..938cb0e --- /dev/null +++ b/src/tools/members.ts @@ -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[] { + 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)); + }, + }, + ]; +} diff --git a/src/tools/messages.ts b/src/tools/messages.ts new file mode 100644 index 0000000..b641776 --- /dev/null +++ b/src/tools/messages.ts @@ -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[] { + 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 = {}; + 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)); + }, + }, + ]; +} diff --git a/src/tools/projects.ts b/src/tools/projects.ts new file mode 100644 index 0000000..ba6c1f6 --- /dev/null +++ b/src/tools/projects.ts @@ -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[] { + 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)); + }, + }, + ]; +} diff --git a/src/tools/steps.ts b/src/tools/steps.ts new file mode 100644 index 0000000..3f66174 --- /dev/null +++ b/src/tools/steps.ts @@ -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[] { + 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`); + }, + }, + ]; +} diff --git a/src/tools/tasks.ts b/src/tools/tasks.ts new file mode 100644 index 0000000..bc2250e --- /dev/null +++ b/src/tools/tasks.ts @@ -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[] { + 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 = {}; + 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}`); + }, + }, + ]; +} diff --git a/src/tools/types.ts b/src/tools/types.ts new file mode 100644 index 0000000..a7ae20c --- /dev/null +++ b/src/tools/types.ts @@ -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; +} diff --git a/src/tracker/registration.ts b/src/tracker/registration.ts index bfdcb69..9d28af9 100644 --- a/src/tracker/registration.ts +++ b/src/tracker/registration.ts @@ -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 | null = null; private currentTasks = new Set(); constructor( - private client: TrackerClient, private config: AgentConfig, ) {} - private callbackUrl = ''; - private retryTimer: ReturnType | null = null; - private registered = false; - - async register(callbackUrl: string): Promise { - this.callbackUrl = callbackUrl; - await this.tryRegister(); - } - - private async tryRegister(): Promise { - 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 { + 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 {