feat: agent messaging — direct chronological messages between agents
New: agent_send, agent_inbox, agent_conversation MCP tools.
API: /v1/messages/send, /v1/messages/inbox, /v1/messages/conversation/{agent}
Model: AgentMessage with inbox, unread, conversation scopes.
Separate channel from semantic brain search. Messages are chronological,
not vector-searched. Agents can now have direct conversations.
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
85dd0555ac
commit
37f6d61368
7 changed files with 359 additions and 0 deletions
BIN
core-agent
BIN
core-agent
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
141
pkg/brain/messaging.go
Normal file
141
pkg/brain/messaging.go
Normal file
|
|
@ -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
|
||||
}
|
||||
110
src/php/Controllers/Api/MessageController.php
Normal file
110
src/php/Controllers/Api/MessageController.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Controllers\Api;
|
||||
|
||||
use Core\Mod\Agentic\Models\AgentMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /v1/messages/inbox — unread messages for the requesting agent.
|
||||
*/
|
||||
public function inbox(Request $request): JsonResponse
|
||||
{
|
||||
$agent = $request->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]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('agent_messages')) {
|
||||
Schema::create('agent_messages', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
60
src/php/Models/AgentMessage.php
Normal file
60
src/php/Models/AgentMessage.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Models;
|
||||
|
||||
use Core\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* AgentMessage — direct messages between agents.
|
||||
*
|
||||
* Not semantic, not vector-searched. Just chronological messages.
|
||||
*/
|
||||
class AgentMessage extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'from_agent',
|
||||
'to_agent',
|
||||
'content',
|
||||
'subject',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'read_at' => '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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue