diff --git a/core-agent b/core-agent index 72e40fb..03133b8 100755 Binary files a/core-agent and b/core-agent differ diff --git a/pkg/brain/direct.go b/pkg/brain/direct.go index 6c4bd97..3fd256b 100644 --- a/pkg/brain/direct.go +++ b/pkg/brain/direct.go @@ -80,6 +80,9 @@ func (s *DirectSubsystem) RegisterTools(server *mcp.Server) { Name: "brain_forget", Description: "Remove a memory from OpenBrain by ID.", }, s.forget) + + // Agent messaging — direct, chronological, not semantic + s.RegisterMessagingTools(server) } // Shutdown implements mcp.SubsystemWithShutdown. diff --git a/pkg/brain/messaging.go b/pkg/brain/messaging.go new file mode 100644 index 0000000..cb9a823 --- /dev/null +++ b/pkg/brain/messaging.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RegisterMessagingTools adds agent messaging tools to the MCP server. +func (s *DirectSubsystem) RegisterMessagingTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "agent_send", + Description: "Send a message to another agent. Direct, chronological, not semantic.", + }, s.sendMessage) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agent_inbox", + Description: "Check your inbox — latest messages sent to you.", + }, s.inbox) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agent_conversation", + Description: "View conversation thread with a specific agent.", + }, s.conversation) +} + +// Input/Output types + +type SendInput struct { + To string `json:"to"` + Content string `json:"content"` + Subject string `json:"subject,omitempty"` +} + +type SendOutput struct { + Success bool `json:"success"` + ID int `json:"id"` + To string `json:"to"` +} + +type InboxInput struct{} + +type MessageItem struct { + ID int `json:"id"` + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject,omitempty"` + Content string `json:"content"` + Read bool `json:"read"` + CreatedAt string `json:"created_at"` +} + +type InboxOutput struct { + Success bool `json:"success"` + Messages []MessageItem `json:"messages"` +} + +type ConversationInput struct { + Agent string `json:"agent"` +} + +type ConversationOutput struct { + Success bool `json:"success"` + Messages []MessageItem `json:"messages"` +} + +// Handlers + +func (s *DirectSubsystem) sendMessage(ctx context.Context, _ *mcp.CallToolRequest, input SendInput) (*mcp.CallToolResult, SendOutput, error) { + if input.To == "" || input.Content == "" { + return nil, SendOutput{}, fmt.Errorf("to and content are required") + } + + result, err := s.apiCall(ctx, "POST", "/v1/messages/send", map[string]any{ + "to": input.To, + "from": agentName(), + "content": input.Content, + "subject": input.Subject, + }) + if err != nil { + return nil, SendOutput{}, err + } + + data, _ := result["data"].(map[string]any) + id, _ := data["id"].(float64) + + return nil, SendOutput{ + Success: true, + ID: int(id), + To: input.To, + }, nil +} + +func (s *DirectSubsystem) inbox(ctx context.Context, _ *mcp.CallToolRequest, input InboxInput) (*mcp.CallToolResult, InboxOutput, error) { + result, err := s.apiCall(ctx, "GET", "/v1/messages/inbox?agent="+agentName(), nil) + if err != nil { + return nil, InboxOutput{}, err + } + + return nil, InboxOutput{ + Success: true, + Messages: parseMessages(result), + }, nil +} + +func (s *DirectSubsystem) conversation(ctx context.Context, _ *mcp.CallToolRequest, input ConversationInput) (*mcp.CallToolResult, ConversationOutput, error) { + if input.Agent == "" { + return nil, ConversationOutput{}, fmt.Errorf("agent is required") + } + + result, err := s.apiCall(ctx, "GET", "/v1/messages/conversation/"+input.Agent+"?me="+agentName(), nil) + if err != nil { + return nil, ConversationOutput{}, err + } + + return nil, ConversationOutput{ + Success: true, + Messages: parseMessages(result), + }, nil +} + +func parseMessages(result map[string]any) []MessageItem { + var messages []MessageItem + data, _ := result["data"].([]any) + for _, m := range data { + mm, _ := m.(map[string]any) + messages = append(messages, MessageItem{ + ID: int(mm["id"].(float64)), + From: fmt.Sprintf("%v", mm["from"]), + To: fmt.Sprintf("%v", mm["to"]), + Subject: fmt.Sprintf("%v", mm["subject"]), + Content: fmt.Sprintf("%v", mm["content"]), + Read: mm["read"] == true, + CreatedAt: fmt.Sprintf("%v", mm["created_at"]), + }) + } + return messages +} diff --git a/src/php/Controllers/Api/MessageController.php b/src/php/Controllers/Api/MessageController.php new file mode 100644 index 0000000..8fa3e07 --- /dev/null +++ b/src/php/Controllers/Api/MessageController.php @@ -0,0 +1,110 @@ +query('agent', $request->header('X-Agent-Name', 'unknown')); + $workspaceId = $request->attributes->get('workspace_id'); + + $messages = AgentMessage::where('workspace_id', $workspaceId) + ->inbox($agent) + ->limit(20) + ->get() + ->map(fn (AgentMessage $m) => [ + 'id' => $m->id, + 'from' => $m->from_agent, + 'to' => $m->to_agent, + 'subject' => $m->subject, + 'content' => $m->content, + 'read' => $m->read_at !== null, + 'created_at' => $m->created_at->toIso8601String(), + ]); + + return response()->json(['data' => $messages]); + } + + /** + * GET /v1/messages/conversation/{agent} — thread between requesting agent and target. + */ + public function conversation(Request $request, string $agent): JsonResponse + { + $me = $request->query('me', $request->header('X-Agent-Name', 'unknown')); + $workspaceId = $request->attributes->get('workspace_id'); + + $messages = AgentMessage::where('workspace_id', $workspaceId) + ->conversation($me, $agent) + ->limit(50) + ->get() + ->map(fn (AgentMessage $m) => [ + 'id' => $m->id, + 'from' => $m->from_agent, + 'to' => $m->to_agent, + 'subject' => $m->subject, + 'content' => $m->content, + 'read' => $m->read_at !== null, + 'created_at' => $m->created_at->toIso8601String(), + ]); + + return response()->json(['data' => $messages]); + } + + /** + * POST /v1/messages/send — send a message to another agent. + */ + public function send(Request $request): JsonResponse + { + $validated = $request->validate([ + 'to' => 'required|string|max:100', + 'content' => 'required|string|max:10000', + 'from' => 'required|string|max:100', + 'subject' => 'nullable|string|max:255', + ]); + + $workspaceId = $request->attributes->get('workspace_id'); + + $message = AgentMessage::create([ + 'workspace_id' => $workspaceId, + 'from_agent' => $validated['from'], + 'to_agent' => $validated['to'], + 'content' => $validated['content'], + 'subject' => $validated['subject'] ?? null, + ]); + + return response()->json([ + 'data' => [ + 'id' => $message->id, + 'from' => $message->from_agent, + 'to' => $message->to_agent, + 'created_at' => $message->created_at->toIso8601String(), + ], + ], 201); + } + + /** + * POST /v1/messages/{id}/read — mark a message as read. + */ + public function markRead(Request $request, int $id): JsonResponse + { + $workspaceId = $request->attributes->get('workspace_id'); + + $message = AgentMessage::where('workspace_id', $workspaceId) + ->findOrFail($id); + + $message->markRead(); + + return response()->json(['data' => ['id' => $id, 'read' => true]]); + } +} diff --git a/src/php/Migrations/0001_01_01_000012_create_agent_messages_table.php b/src/php/Migrations/0001_01_01_000012_create_agent_messages_table.php new file mode 100644 index 0000000..f5f3caa --- /dev/null +++ b/src/php/Migrations/0001_01_01_000012_create_agent_messages_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('from_agent', 100); + $table->string('to_agent', 100); + $table->text('content'); + $table->string('subject')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index(['to_agent', 'read_at']); + $table->index(['from_agent', 'to_agent', 'created_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('agent_messages'); + } +}; diff --git a/src/php/Models/AgentMessage.php b/src/php/Models/AgentMessage.php new file mode 100644 index 0000000..50eb5a1 --- /dev/null +++ b/src/php/Models/AgentMessage.php @@ -0,0 +1,60 @@ + 'datetime', + ]; + + public function scopeInbox(Builder $query, string $agent): Builder + { + return $query->where('to_agent', $agent)->orderByDesc('created_at'); + } + + public function scopeUnread(Builder $query): Builder + { + return $query->whereNull('read_at'); + } + + public function scopeConversation(Builder $query, string $agent1, string $agent2): Builder + { + return $query->where(function ($q) use ($agent1, $agent2) { + $q->where(function ($q2) use ($agent1, $agent2) { + $q2->where('from_agent', $agent1)->where('to_agent', $agent2); + })->orWhere(function ($q2) use ($agent1, $agent2) { + $q2->where('from_agent', $agent2)->where('to_agent', $agent1); + }); + })->orderByDesc('created_at'); + } + + public function markRead(): void + { + if (! $this->read_at) { + $this->update(['read_at' => now()]); + } + } +} diff --git a/src/php/Routes/api.php b/src/php/Routes/api.php index b41fbe2..1a4e627 100644 --- a/src/php/Routes/api.php +++ b/src/php/Routes/api.php @@ -86,3 +86,14 @@ Route::middleware(AgentApiAuth::class.':sprints.write')->group(function () { Route::patch('v1/sprints/{slug}', [SprintController::class, 'update']); Route::delete('v1/sprints/{slug}', [SprintController::class, 'destroy']); }); + +// Agent messaging +Route::middleware(AgentApiAuth::class.':plans.read')->group(function () { + Route::get('v1/messages/inbox', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'inbox']); + Route::get('v1/messages/conversation/{agent}', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'conversation']); +}); + +Route::middleware(AgentApiAuth::class.':plans.write')->group(function () { + Route::post('v1/messages/send', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'send']); + Route::post('v1/messages/{id}/read', [\Core\Mod\Agentic\Controllers\Api\MessageController::class, 'markRead']); +});