feat: implementar tipo Robot como runtime ligero para bots command-only

Introduce la separacion Robot vs Agent en el sistema:

- agents/types.go: interfaz Runner comun (Run, Stop, Done, RegisterCommand)
  que tanto Agent como Robot satisfacen
- agents/robot.go: struct Robot — runtime minimo que solo conecta a Matrix
  y despacha comandos. Sin LLM, reglas, memoria, knowledge, skills ni tools.
  Mensajes normales se ignoran silenciosamente
- internal/config/schema.go: campo Type en AgentMeta ("agent"|"robot")
- cmd/launcher: usa Runner interface para manejar ambos tipos uniformemente.
  Si cfg.Agent.Type == "robot" crea NewRobot en vez de New (tanto en
  arranque como en hot-reload)
- agents/_template_robot/config.yaml: plantilla minima (~55 lineas) para
  robots command-only

El Robot soporta built-in commands reducidos (help, ping, status, info,
version) y comandos custom via RegisterCommand. No incluye tools, tool,
clear ni prompts ya que no tiene LLM ni memoria.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 23:12:33 +00:00
parent ac5cde3f3e
commit f11f76f39b
6 changed files with 477 additions and 67 deletions
+45 -29
View File
@@ -158,8 +158,6 @@ func main() {
continue
}
rules := rulesFor(cfg.Agent.ID, logger)
// Per-agent logger → writes to logs/<agent-id>/YYYY-MM-DD.jsonl
agentLogger, agentCleanup, aErr := agentlog.NewAgentLogger(agentlog.LoggerConfig{
BaseDir: logDir,
@@ -172,41 +170,59 @@ func main() {
agentCleanup = func() {}
}
// Resolve centralized ACL for this agent
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy)
agentLogger.Debug("resolved acl for agent",
"agent", cfg.Agent.ID,
"acl_empty", agentACL.Empty(),
)
// Branch: robot (command-only, lightweight) vs agent (full runtime).
var runner agents.Runner
a, err := agents.New(cfg, rules, agentACL, agentLogger)
if err != nil {
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", err)
agentCleanup()
continue
}
if cfg.Agent.Type == "robot" {
robot, rErr := agents.NewRobot(cfg, agentLogger)
if rErr != nil {
logger.Error("failed to create robot", "id", cfg.Agent.ID, "err", rErr)
agentCleanup()
continue
}
runner = robot
agentLogger.Info("created robot", "id", cfg.Agent.ID)
} else {
rules := rulesFor(cfg.Agent.ID, logger)
// Connect agent to bus for orchestration
a.SetBus(agentBus)
// Resolve centralized ACL for this agent
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, deps.secPolicy)
agentLogger.Debug("resolved acl for agent",
"agent", cfg.Agent.ID,
"acl_empty", agentACL.Empty(),
)
// If orchestrator is active, wire interceptor and membership notify
if orch != nil {
a.SetInterceptor(orch.orchestrator.Intercept)
a.SetMembershipNotify(orch.orchestrator.NotifyMembership)
a, cErr := agents.New(cfg, rules, agentACL, agentLogger)
if cErr != nil {
logger.Error("failed to create agent", "id", cfg.Agent.ID, "err", cErr)
agentCleanup()
continue
}
orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
ID: cfg.Agent.ID,
MatrixUserID: cfg.Matrix.UserID,
Description: cfg.Agent.Description,
Capabilities: cfg.Agent.Tags,
})
// Connect agent to bus for orchestration
a.SetBus(agentBus)
// Grab the first available Matrix client for room scanning
scannerOnce.set(a.RawMatrixClient())
// If orchestrator is active, wire interceptor and membership notify
if orch != nil {
a.SetInterceptor(orch.orchestrator.Intercept)
a.SetMembershipNotify(orch.orchestrator.NotifyMembership)
orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
ID: cfg.Agent.ID,
MatrixUserID: cfg.Matrix.UserID,
Description: cfg.Agent.Description,
Capabilities: cfg.Agent.Tags,
})
// Grab the first available Matrix client for room scanning
scannerOnce.set(a.RawMatrixClient())
}
runner = a
}
registry.register(&runningAgent{
agent: a,
runner: runner,
cfg: cfg,
cfgPath: path,
logger: agentLogger,
+60 -38
View File
@@ -17,9 +17,9 @@ import (
agentlog "github.com/enmanuel/agents/shell/logger"
)
// runningAgent holds a live agent and the metadata needed to recreate it.
// runningAgent holds a live runner (Agent or Robot) and the metadata needed to recreate it.
type runningAgent struct {
agent *agents.Agent
runner agents.Runner
cfg *config.AgentConfig
cfgPath string
logger *slog.Logger
@@ -50,21 +50,26 @@ func newAgentRegistry(deps *launchDeps) *agentRegistry {
}
}
// register adds a running agent to the registry and starts its goroutine.
// register adds a running agent/robot to the registry and starts its goroutine.
func (r *agentRegistry) register(ra *runningAgent) {
r.mu.Lock()
r.agents[ra.cfg.Agent.ID] = ra
r.mu.Unlock()
runtimeType := ra.cfg.Agent.Type
if runtimeType == "" {
runtimeType = "agent"
}
go func() {
ra.logger.Info("agent running")
if err := ra.agent.Run(r.deps.parentCtx); err != nil {
ra.logger.Error("agent stopped with error", "err", err)
ra.logger.Info("runner started", "type", runtimeType)
if err := ra.runner.Run(r.deps.parentCtx); err != nil {
ra.logger.Error("runner stopped with error", "err", err, "type", runtimeType)
}
}()
}
// stopAndWait stops a running agent and waits for it to finish.
// stopAndWait stops a running agent/robot and waits for it to finish.
// Caller must NOT hold r.mu.
func (r *agentRegistry) stopAndWait(id string) {
r.mu.Lock()
@@ -74,11 +79,11 @@ func (r *agentRegistry) stopAndWait(id string) {
return
}
ra.agent.Stop()
ra.runner.Stop()
select {
case <-ra.agent.Done():
case <-ra.runner.Done():
case <-time.After(10 * time.Second):
ra.logger.Warn("agent did not stop within 10s, forcing", "id", id)
ra.logger.Warn("runner did not stop within 10s, forcing", "id", id)
}
// Unsubscribe from bus so no stale channel remains.
@@ -133,32 +138,45 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) []
newCleanup = func() {}
}
// 5. Create new agent (validates config before discarding the old one).
rules := rulesFor(cfg.Agent.ID, newLogger)
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, r.deps.secPolicy)
newLogger.Debug("resolved acl for agent (reload)", "agent", cfg.Agent.ID, "acl_empty", agentACL.Empty())
newAgent, err := agents.New(cfg, rules, agentACL, newLogger)
if err != nil {
newLogger.Error("reload: failed to create agent", "id", id, "err", err)
newCleanup()
return
}
// 5. Create new runner (validates config before discarding the old one).
var newRunner agents.Runner
// 6. Wire bus and orchestration.
newAgent.SetBus(r.deps.agentBus)
if r.deps.orch != nil {
newAgent.SetInterceptor(r.deps.orch.orchestrator.Intercept)
newAgent.SetMembershipNotify(r.deps.orch.orchestrator.NotifyMembership)
r.deps.orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
ID: cfg.Agent.ID,
MatrixUserID: cfg.Matrix.UserID,
Description: cfg.Agent.Description,
Capabilities: cfg.Agent.Tags,
})
if cfg.Agent.Type == "robot" {
robot, rErr := agents.NewRobot(cfg, newLogger)
if rErr != nil {
newLogger.Error("reload: failed to create robot", "id", id, "err", rErr)
newCleanup()
return
}
newRunner = robot
} else {
rules := rulesFor(cfg.Agent.ID, newLogger)
agentACL := pksecurity.ResolveACL(cfg.Agent.ID, r.deps.secPolicy)
newLogger.Debug("resolved acl for agent (reload)", "agent", cfg.Agent.ID, "acl_empty", agentACL.Empty())
newAgent, aErr := agents.New(cfg, rules, agentACL, newLogger)
if aErr != nil {
newLogger.Error("reload: failed to create agent", "id", id, "err", aErr)
newCleanup()
return
}
// Wire bus and orchestration (only for agents, not robots).
newAgent.SetBus(r.deps.agentBus)
if r.deps.orch != nil {
newAgent.SetInterceptor(r.deps.orch.orchestrator.Intercept)
newAgent.SetMembershipNotify(r.deps.orch.orchestrator.NotifyMembership)
r.deps.orch.orchestrator.RegisterParticipant(orchestration.ParticipantInfo{
ID: cfg.Agent.ID,
MatrixUserID: cfg.Matrix.UserID,
Description: cfg.Agent.Description,
Capabilities: cfg.Agent.Tags,
})
}
newRunner = newAgent
}
newRA := &runningAgent{
agent: newAgent,
runner: newRunner,
cfg: cfg,
cfgPath: cfgPath,
logger: newLogger,
@@ -170,14 +188,18 @@ func (r *agentRegistry) reload(id string, rulesFor func(string, *slog.Logger) []
r.mu.Unlock()
// 7. Start new goroutine.
runtimeType := cfg.Agent.Type
if runtimeType == "" {
runtimeType = "agent"
}
go func() {
newLogger.Info("agent running")
if err := newAgent.Run(r.deps.parentCtx); err != nil {
newLogger.Error("agent stopped with error", "err", err)
newLogger.Info("runner started", "type", runtimeType)
if err := newRunner.Run(r.deps.parentCtx); err != nil {
newLogger.Error("runner stopped with error", "err", err, "type", runtimeType)
}
}()
newLogger.Info("agent_reloaded", "id", id)
newLogger.Info("runner_reloaded", "id", id, "type", runtimeType)
}
// reloadAll reloads every registered agent sequentially.
@@ -194,12 +216,12 @@ func (r *agentRegistry) reloadAll(rulesFor func(string, *slog.Logger) []decision
}
}
// waitAll blocks until all registered agents have stopped.
// waitAll blocks until all registered runners have stopped.
func (r *agentRegistry) waitAll() {
r.mu.Lock()
dones := make([]<-chan struct{}, 0, len(r.agents))
for _, ra := range r.agents {
dones = append(dones, ra.agent.Done())
dones = append(dones, ra.runner.Done())
}
r.mu.Unlock()