Ajustes de estructura

This commit is contained in:
Jordan Diaz
2026-05-10 18:47:08 +00:00
parent 44cb956f95
commit 5e64bbdfc8
10 changed files with 170 additions and 17 deletions

View File

@@ -12,6 +12,7 @@ context_sections:
- task_state - task_state
allowed_tools: [] allowed_tools: []
model_id: null model_id: null
# planner_model_id: null # null → usa AGENTIC_PLANNER_MODEL_ID del .env
stream_deltas: true stream_deltas: true
kb_load_strategy: top_n kb_load_strategy: top_n
kb_max_tokens: 4000 kb_max_tokens: 4000

View File

@@ -1,5 +1,6 @@
Eres el **asistente de desarrollo de Acai CMS**. Trabajas en un chat conversacional continuo: el usuario te hace peticiones de muy distinto alcance dentro de la misma sesión, y el contexto del proyecto se va acumulando turno a turno. Tu misión es resolver cada petición con el mínimo número de pasos posibles, **reutilizando** lo que ya sabes del proyecto y los turnos anteriores. Eres el **asistente de desarrollo de Acai CMS**. Trabajas en un chat conversacional continuo: el usuario te hace peticiones de muy distinto alcance dentro de la misma sesión, y el contexto del proyecto se va acumulando turno a turno. Tu misión es resolver cada petición con el mínimo número de pasos posibles, **reutilizando** lo que ya sabes del proyecto y los turnos anteriores.
<!-- PLANNER_SECTION_START -->
# Cuándo planificar y cuándo ejecutar directo # Cuándo planificar y cuándo ejecutar directo
Antes de actuar, juzga el alcance de la petición. Hay dos modos de operación: Antes de actuar, juzga el alcance de la petición. Hay dos modos de operación:
@@ -39,6 +40,7 @@ Tras recibirlo:
El plan persiste en `Active Plan` (lo verás en el contexto) hasta que termines o el usuario cambie de tema. Si retomas el mismo objetivo en un turno futuro, continúa por el `→ Step N` actual. El plan persiste en `Active Plan` (lo verás en el contexto) hasta que termines o el usuario cambie de tema. Si retomas el mismo objetivo en un turno futuro, continúa por el `→ Step N` actual.
Si el usuario te corrige a media ejecución ("no, mejor no toques el header"): ajusta los steps afectados y continúa con los demás. Si la corrección invalida el plan, llama `acai_plan_advance({"abandon": true})` y empieza de nuevo. Si el usuario te corrige a media ejecución ("no, mejor no toques el header"): ajusta los steps afectados y continúa con los demás. Si la corrección invalida el plan, llama `acai_plan_advance({"abandon": true})` y empieza de nuevo.
<!-- PLANNER_SECTION_END -->
# Estructura del proyecto Acai (referencia mínima) # Estructura del proyecto Acai (referencia mínima)

View File

@@ -46,9 +46,10 @@ class SendMessageRequest(BaseModel):
message: str message: str
stream: bool = False stream: bool = False
agent_id: str | None = None agent_id: str | None = None
# 'auto' = el agente decide (heuristica trivial-vs-complex). 'force' = forzar # 'off' (default): la tool acai_plan no se expone al modelo, ejecuta directo.
# acai_plan antes de cualquier ejecucion. UI: toggle en ChatPanel. # 'force': system prompt obliga a llamar acai_plan antes de ejecutar.
plan_mode: str = "auto" # 'auto' (legacy): se trata como 'off'. UI: toggle en ChatPanel.
plan_mode: str = "off"
class CompletionRequest(BaseModel): class CompletionRequest(BaseModel):
@@ -298,9 +299,14 @@ async def send_message(
# Plan mode controlado por el usuario desde el toggle del ChatPanel. # Plan mode controlado por el usuario desde el toggle del ChatPanel.
# 'auto' (default): heuristica del modelo trivial-vs-complex. # 'auto' (default): heuristica del modelo trivial-vs-complex.
# 'force': el agente DEBE llamar acai_plan como primera accion. # 'force': el agente DEBE llamar acai_plan como primera accion.
plan_mode = (body.plan_mode or "auto").lower() # 'off' (default): la tool acai_plan NO se expone al modelo, ejecuta directo.
if plan_mode not in ("auto", "force"): # 'force': la tool se expone y system prompt obliga a llamarla primero.
plan_mode = "auto" # 'auto' (legacy): se trata como 'off'.
plan_mode = (body.plan_mode or "off").lower()
if plan_mode == "auto":
plan_mode = "off"
if plan_mode not in ("off", "force"):
plan_mode = "off"
session.metadata["plan_mode"] = plan_mode session.metadata["plan_mode"] = plan_mode
from ..mcp.manager import MCPManager from ..mcp.manager import MCPManager

View File

@@ -34,6 +34,13 @@ class Settings(BaseSettings):
openai_base_url: str = "" # Custom base URL (for MiniMax, DeepInfra, etc.) openai_base_url: str = "" # Custom base URL (for MiniMax, DeepInfra, etc.)
default_model_provider: str = "claude" default_model_provider: str = "claude"
default_model_id: str = "claude-sonnet-4-20250514" default_model_id: str = "claude-sonnet-4-20250514"
# Modelo override SOLO para el sub-loop del planner (acai_plan). Si vacio,
# usa default_model_id. Pensado para usar un modelo mas potente al planificar
# (p.ej. deepseek-v4-pro) y otro mas rapido al ejecutar (p.ej. deepseek-v4-flash).
planner_model_id: str = ""
# Max tokens del planner. Mas alto que el agente principal porque Pro con
# thinking puede gastar 2-4k tokens razonando antes de emitir el JSON del plan.
planner_max_tokens: int = 16000
max_tokens: int = 4096 max_tokens: int = 4096
temperature: float = 0.3 temperature: float = 0.3

View File

@@ -349,7 +349,20 @@ class ContextEngine:
# por el registry al cargar). Aqui solo se añaden reglas de sesion # por el registry al cargar). Aqui solo se añaden reglas de sesion
# cuando existen — el bloque hardcoded de "Contrato de Contexto" que # cuando existen — el bloque hardcoded de "Contrato de Contexto" que
# vivia aqui se ha movido a `agents/_shared/contract.md` (Fase 3). # vivia aqui se ha movido a `agents/_shared/contract.md` (Fase 3).
parts = [agent.system_prompt or ""] system_prompt = agent.system_prompt or ""
# Si el usuario tiene el toggle de plan desactivado (plan_mode != "force"),
# quitamos la seccion del system prompt entre <!-- PLANNER_SECTION_START -->
# y <!-- PLANNER_SECTION_END -->. Asi el modelo no ve instrucciones para
# llamar acai_plan y no se inventa el namespace `acai_code__acai_plan`.
if (session.metadata.get("plan_mode") or "off").lower() != "force":
import re
system_prompt = re.sub(
r"<!--\s*PLANNER_SECTION_START\s*-->.*?<!--\s*PLANNER_SECTION_END\s*-->\n*",
"",
system_prompt,
flags=re.DOTALL,
)
parts = [system_prompt]
if session.immutable_rules: if session.immutable_rules:
parts.append("\n\n## Session Rules\n") parts.append("\n\n## Session Rules\n")
for rule in session.immutable_rules: for rule in session.immutable_rules:

View File

@@ -19,6 +19,7 @@ class AgentProfile(BaseModel):
system_prompt: str = "" system_prompt: str = ""
allowed_tools: list[str] = Field(default_factory=list) allowed_tools: list[str] = Field(default_factory=list)
model_id: str | None = None model_id: str | None = None
planner_model_id: str | None = None # override del modelo solo para el sub-loop del planner
temperature: float | None = None temperature: float | None = None
max_tokens: int | None = None max_tokens: int | None = None
context_sections: list[str] = Field( context_sections: list[str] = Field(

View File

@@ -81,9 +81,11 @@ class BaseAgent:
conversation=conversation, conversation=conversation,
) )
# Prepare tool definitions # Prepare tool definitions. plan_mode "off" oculta acai_plan al
# modelo (toggle del UI desactivado). "force" la expone normalmente.
tool_defs = self._get_allowed_tools( tool_defs = self._get_allowed_tools(
followup_mode=str(session.metadata.get("followup_mode", "none")), followup_mode=str(session.metadata.get("followup_mode", "none")),
plan_mode=str(session.metadata.get("plan_mode", "off") or "off"),
) )
# Stream model response # Stream model response
@@ -146,6 +148,17 @@ class BaseAgent:
turn_blocks_by_index[chunk.block_index] = blk turn_blocks_by_index[chunk.block_index] = blk
if blk.get("type") == "thinking": if blk.get("type") == "thinking":
blk["thinking"] = blk.get("thinking", "") + chunk.thinking_delta blk["thinking"] = blk.get("thinking", "") + chunk.thinking_delta
if self.profile.stream_deltas:
await self.sse.emit(
EventType.AGENT_DELTA,
{
"agent": self.profile.role,
"thinking_delta": chunk.thinking_delta,
"block_index": chunk.block_index,
"step": step,
},
session_id=session.session_id,
)
if chunk.thinking_signature and chunk.block_index >= 0: if chunk.thinking_signature and chunk.block_index >= 0:
blk = turn_blocks_by_index.get(chunk.block_index) blk = turn_blocks_by_index.get(chunk.block_index)
@@ -941,12 +954,18 @@ class BaseAgent:
# ---- Allowed tools -------------------------------------------------------- # ---- Allowed tools --------------------------------------------------------
def _get_allowed_tools(self, followup_mode: str = "none") -> list[dict[str, Any]]: def _get_allowed_tools(
self,
followup_mode: str = "none",
plan_mode: str = "force",
) -> list[dict[str, Any]]:
"""Return tool definitions filtered by this agent's allowed_tools. """Return tool definitions filtered by this agent's allowed_tools.
Si el agente tiene `has_planner_tool=True`, anade definiciones sinteticas Si el agente tiene `has_planner_tool=True` Y `plan_mode == "force"`,
de `acai_plan` y `acai_plan_advance` (Fase 5: la tool interna no anade definiciones sinteticas de `acai_plan` y `acai_plan_advance`
atraviesa MCP — se intercepta en `_execute_tool`). (la tool interna no atraviesa MCP — se intercepta en `_execute_tool`).
Cuando `plan_mode != "force"` (toggle del UI desactivado), las tools
del planner NO se exponen y el agente ejecuta directo.
""" """
if followup_mode == "transform": if followup_mode == "transform":
return [] return []
@@ -958,7 +977,7 @@ class BaseAgent:
else: else:
tool_defs = list(all_tools) tool_defs = list(all_tools)
if self.profile.has_planner_tool: if self.profile.has_planner_tool and plan_mode == "force":
tool_defs.append({ tool_defs.append({
"name": "acai_plan", "name": "acai_plan",
"description": ( "description": (

View File

@@ -22,6 +22,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from ..adapters.base import ModelAdapter, ModelConfig from ..adapters.base import ModelAdapter, ModelConfig
from ..config import settings
from ..mcp.manager import MCPManager from ..mcp.manager import MCPManager
from ..models.agent import AgentProfile from ..models.agent import AgentProfile
from .tool_groups import PLANNER_TOOLS, strip_namespace from .tool_groups import PLANNER_TOOLS, strip_namespace
@@ -29,6 +30,27 @@ from .tool_groups import PLANNER_TOOLS, strip_namespace
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _serialize_thinking_blocks(
turn_thinking_blocks: dict[int, dict[str, str]],
) -> list[dict[str, Any]]:
"""Convierte los thinking blocks acumulados de un turno en bloques
Anthropic-style, ordenados por block_index. DeepSeek (y Anthropic) exigen
que los assistant messages reenvien los thinking blocks con su signature
en turnos siguientes; si no, devuelven 400.
"""
out: list[dict[str, Any]] = []
for idx in sorted(turn_thinking_blocks.keys()):
blk = turn_thinking_blocks[idx]
if not blk.get("thinking"):
continue
out.append({
"type": "thinking",
"thinking": blk["thinking"],
"signature": blk.get("signature", ""),
})
return out
@dataclass @dataclass
class PlannerResult: class PlannerResult:
"""Resultado del sub-loop del planner.""" """Resultado del sub-loop del planner."""
@@ -179,8 +201,20 @@ async def run_planner_subloop(
] ]
config = ModelConfig( config = ModelConfig(
model_id=agent_profile.model_id or "", # Resolucion del modelo del planner (mas a menos prioritario):
max_tokens=agent_profile.max_tokens or 4096, # 1) planner_model_id del agent yaml (override per-agent)
# 2) AGENTIC_PLANNER_MODEL_ID en .env (override global)
# 3) model_id del agent (mismo que ejecuciones)
# 4) default_model_id global (fallback final)
model_id=(
agent_profile.planner_model_id
or settings.planner_model_id
or agent_profile.model_id
or settings.default_model_id
),
# Mas tokens que el agente principal: Pro con thinking puede gastar
# 2-4k razonando antes del JSON del plan; con 4k se truncaba.
max_tokens=settings.planner_max_tokens or 16000,
# Temperatura mas baja que el agente principal — queremos JSON limpio. # Temperatura mas baja que el agente principal — queremos JSON limpio.
temperature=0.1, temperature=0.1,
) )
@@ -196,6 +230,10 @@ async def run_planner_subloop(
active_tools: dict[str, dict[str, Any]] = {} active_tools: dict[str, dict[str, Any]] = {}
tool_calls_this_step: list[dict[str, Any]] = [] tool_calls_this_step: list[dict[str, Any]] = []
finish_reason = "" finish_reason = ""
# Bloques de thinking de ESTE turno indexados por block_index. DeepSeek
# (y cualquier API Anthropic con thinking on) exige reenviar los bloques
# thinking + signature en los assistant messages de turnos siguientes.
turn_thinking_blocks: dict[int, dict[str, str]] = {}
async for chunk in model_adapter.stream( async for chunk in model_adapter.stream(
messages=messages, messages=messages,
@@ -207,6 +245,17 @@ async def run_planner_subloop(
if chunk.thinking_delta: if chunk.thinking_delta:
accumulated_thinking += chunk.thinking_delta accumulated_thinking += chunk.thinking_delta
if chunk.block_index >= 0:
blk = turn_thinking_blocks.setdefault(
chunk.block_index, {"thinking": "", "signature": ""}
)
blk["thinking"] += chunk.thinking_delta
if chunk.thinking_signature and chunk.block_index >= 0:
blk = turn_thinking_blocks.setdefault(
chunk.block_index, {"thinking": "", "signature": ""}
)
blk["signature"] = chunk.thinking_signature
if chunk.tool_name and chunk.tool_call_id: if chunk.tool_name and chunk.tool_call_id:
if chunk.tool_call_id not in active_tools: if chunk.tool_call_id not in active_tools:
@@ -259,7 +308,11 @@ async def run_planner_subloop(
tool_executions=tool_executions_log, tool_executions=tool_executions_log,
) )
# Reintenta con un mensaje de correccion explicito. # Reintenta con un mensaje de correccion explicito.
messages.append({"role": "assistant", "content": full_text or accumulated_text}) # Reenviar thinking blocks (con signature) si los hubo — DeepSeek
# rechaza el siguiente turno si el assistant message los omite.
retry_blocks: list[dict[str, Any]] = _serialize_thinking_blocks(turn_thinking_blocks)
retry_blocks.append({"type": "text", "text": full_text or accumulated_text})
messages.append({"role": "assistant", "content": retry_blocks})
messages.append({ messages.append({
"role": "user", "role": "user",
"content": ( "content": (
@@ -272,7 +325,8 @@ async def run_planner_subloop(
# Si llamo tools, ejecutamos las tools y seguimos el sub-loop. # Si llamo tools, ejecutamos las tools y seguimos el sub-loop.
# Adjuntamos el assistant message con tool_use blocks y los tool_results. # Adjuntamos el assistant message con tool_use blocks y los tool_results.
assistant_blocks: list[dict[str, Any]] = [] # Reenviar thinking blocks (con signature) primero — requerido por DeepSeek.
assistant_blocks: list[dict[str, Any]] = _serialize_thinking_blocks(turn_thinking_blocks)
if full_text: if full_text:
assistant_blocks.append({"type": "text", "text": full_text}) assistant_blocks.append({"type": "text", "text": full_text})
for tc in tool_calls_this_step: for tc in tool_calls_this_step:

View File

@@ -103,6 +103,7 @@ class AgentRegistry:
system_prompt=system_prompt, system_prompt=system_prompt,
allowed_tools=meta.get("allowed_tools", []), allowed_tools=meta.get("allowed_tools", []),
model_id=meta.get("model_id"), model_id=meta.get("model_id"),
planner_model_id=meta.get("planner_model_id"),
temperature=meta.get("temperature"), temperature=meta.get("temperature"),
max_tokens=meta.get("max_tokens"), max_tokens=meta.get("max_tokens"),
context_sections=meta.get("context_sections", [ context_sections=meta.get("context_sections", [

View File

@@ -35,6 +35,8 @@ class ClaudeFormatEmitter:
self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index} self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index}
self._content_blocks: dict[str, list[dict[str, Any]]] = {} self._content_blocks: dict[str, list[dict[str, Any]]] = {}
self._text_accumulator: dict[str, str] = {} self._text_accumulator: dict[str, str] = {}
self._thinking_block_open: dict[str, bool] = {}
self._thinking_block_index: dict[str, int] = {}
def _next_index(self, session_id: str) -> int: def _next_index(self, session_id: str) -> int:
idx = self._block_counter.get(session_id, 0) idx = self._block_counter.get(session_id, 0)
@@ -48,6 +50,8 @@ class ClaudeFormatEmitter:
self._tool_block_index[session_id] = {} self._tool_block_index[session_id] = {}
self._content_blocks[session_id] = [] self._content_blocks[session_id] = []
self._text_accumulator[session_id] = "" self._text_accumulator[session_id] = ""
self._thinking_block_open[session_id] = False
self._thinking_block_index[session_id] = -1
def _push(self, session_id: str, payload: dict[str, Any]) -> None: def _push(self, session_id: str, payload: dict[str, Any]) -> None:
"""Push a formatted line to all subscribers of a session.""" """Push a formatted line to all subscribers of a session."""
@@ -119,7 +123,43 @@ class ClaudeFormatEmitter:
tool_args = data.get("tool_arguments", "") tool_args = data.get("tool_arguments", "")
tool_call_id = data.get("tool_call_id", "") tool_call_id = data.get("tool_call_id", "")
thinking_delta = data.get("thinking_delta", "")
if thinking_delta:
# Cerrar text block abierto si lo hay
self._close_text_block(session_id)
# Abrir thinking block si no esta abierto
if not self._thinking_block_open.get(session_id):
idx = self._next_index(session_id)
self._thinking_block_index[session_id] = idx
self._thinking_block_open[session_id] = True
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_start",
"index": idx,
"content_block": {"type": "thinking", "thinking": ""},
},
})
idx = self._thinking_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_delta",
"index": idx,
"delta": {"type": "thinking_delta", "thinking": thinking_delta},
},
})
return
if delta_text: if delta_text:
# Cerrar thinking block abierto si lo hay antes de texto normal
if self._thinking_block_open.get(session_id):
idx = self._thinking_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {"type": "content_block_stop", "index": idx},
})
self._thinking_block_open[session_id] = False
# Text streaming # Text streaming
if not self._text_block_open.get(session_id): if not self._text_block_open.get(session_id):
self._open_text_block(session_id) self._open_text_block(session_id)
@@ -152,6 +192,15 @@ class ClaudeFormatEmitter:
tool_name = data.get("tool", "unknown") tool_name = data.get("tool", "unknown")
tool_call_id = data.get("tool_call_id", "") tool_call_id = data.get("tool_call_id", "")
# Cerrar thinking block abierto si lo hay
if self._thinking_block_open.get(session_id):
idx = self._thinking_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {"type": "content_block_stop", "index": idx},
})
self._thinking_block_open[session_id] = False
# Close open text block # Close open text block
self._close_text_block(session_id) self._close_text_block(session_id)