Skip to content

03 · Agent Pipeline ​

Third installment of the architecture deep-dive: Agent lifecycle, ConversationSession tool loop, DialogueManager RAG composition, Transferer cross-agent dispatch, tool system, and client abstraction. Corresponds to ARCHITECTURE.md §4.


§4.1 Why Not Use an Off-the-Shelf Agent Framework ​

FrameworkReason for rejection
LangChainAbstraction layer too thick; cannot finely control single-agent lifecycle, tool approval timing, or memory write points
AutoGenMulti-agent collaboration model does not match Familiars' three-tier architecture (Manager → Assistant → Dialogue)
CrewAILocal small models (decider / rewriter / memorizer-writer) need deep integration with the main loop

The custom pipeline treats "plan → tool call → memory → report" as first-class citizens, with explicit interception points at every stage.

§4.2 Agent Class (src/models/assistant.py) ​

4.2.1 Assistant Construction ​

python
class Assistant:
    def __init__(self, id: int, mode='normal', description: str = '',
                 name: str = '', notification_type: str = 'SYSTEM',
                 path_override: Optional[str] = None):
        self.id = id
        self.workspace_route = _resolve_workspace_root()
        
        # Path resolution
        if path_override:
            # Program worker: absolute path, e.g. <repo>/runs/programs/<pid>/agents/<role>
            self.path = path_override
            _agent_dir = os.path.abspath(path_override)
            _agents_parent = os.path.dirname(_agent_dir)
            _memorizer_root = os.path.dirname(_agents_parent)
            _agent_key = os.path.basename(_agent_dir)
            self.memorizer = Memorizer(_memorizer_root, agent_id=_agent_key)
        else:
            self.path = f'{self.workspace_route}/workspace/agents/agent_{self.id}'
            self.memorizer = Memorizer(
                os.path.join(self.workspace_route, "workspace"),
                agent_id=f"agent_{self.id}"
            )
        
        # Core components
        self.vision_processor = VisionProcessor()  # MultimodalTools
        self.dialogue_manager = DialogueManager(
            self.memorizer,
            agent_path=self.path,
            top_k=int(os.getenv("TOP_K") or 5),
            workspace_route=self.workspace_route
        )
        
        # Auto-detect notification type
        if self.notification_type == 'SYSTEM':
            if self.info['os'] == 'Darwin':
                self.notification_type = 'MacOS'
            elif self.info['os'] == 'Windows':
                self.notification_type = 'Windows'
            else:
                self.notification_type = 'DESKTOP'

4.2.2 AssistantManager ​

python
class AssistantManager:
    """Orchestrator for N Assistants.
    
    Responsibilities:
    - Agent lifecycle (create / delete / list)
    - APScheduler cron-driven report aggregation
    - Separate MANAGER_MODEL for executive summaries + persona evolution
    """

4.2.3 Signal Evaluation (Decider Integration) ​

python
def evaluate_signal(
    self,
    *,
    trigger_type: str,           # "new_message" / "external_info" / ...
    trigger_summary: str,         # Trigger summary
    trigger_details: Optional[dict] = None,
    memory: Optional[list[str]] = None,
    user_state: Optional[dict] = None,
    recent_user_messages: Optional[list[str]] = None,
):
    """Ask the decider whether this agent should act on a signal.
    
    Returns a DecisionOutcome (fire: bool, action_category: str, act_score: float).
    Always returns — never raises. Worst case: fire=False with source indicating why.
    """
    from src.decider import DeciderService
    svc = DeciderService.shared()
    return svc.evaluate(
        agent_id=self.id,
        trigger_type=trigger_type,
        trigger_summary=trigger_summary,
        ...
    )

4.2.4 Proactive Messages ​

python
def send_proactive_message(
    self,
    *,
    content: str,
    signal_summary: str,
    action_category: str,
    act_score: Optional[float] = None,
    decision_id: Optional[str] = None,
    dialogue_id: int = 0,
    title: Optional[str] = None,
    fired_at: Optional[float] = None,
    extra: Optional[dict] = None,
) -> Optional[int]:
    """Attach an agent-initiated A-node to dialogue_id and persist.
    
    origin='proactive' + metadata → frontend rating UI can POST feedback.
    Returns the new node id, or None if no dialogue exists.
    """

4.2.5 Notification Channels ​

ChannelImplementationDescription
Windowswin10toast.ToastNotifierNative toast; falls back to desktop_notify on failure
macOSosascript -e 'display notification ...'Notification Center
Desktopsrc.services.desktop_notify.notify_desktopElectron renderer → OS toast
Telegramrequests.post(api.telegram.org/bot{token}/sendMessage)Remote push

§4.3 ConversationSession (src/models/conversation.py) ​

4.3.1 Constructor Parameters ​

python
class ConversationSession:
    def __init__(
        self,
        model: Optional[str] = None,        # Default: select_model(fallback_env="DEFAULT_MODEL")
        timeout: Optional[int] = None,       # Default: TIMEOUT=300s
        tools: Optional[list[dict]] = None,  # OpenAI function schemas
        system_prompt: str = "",
        base_url: Optional[str] = None,
        api_key: Optional[str] = None,
        dispatcher: Optional[ToolDispatcher] = None,
    ):

4.3.2 CircuitBreaker ​

python
class CircuitBreaker:
    """Per-URL circuit breaker, three states: CLOSED → OPEN → HALF_OPEN.
    
    CLOSED: requests flow normally, counting failures.
    OPEN: all requests immediately rejected for recovery_seconds.
    HALF_OPEN: allows one probe request; success → CLOSED, failure → OPEN.
    """
    
    def __init__(
        self,
        failure_threshold: int = 5,         # CB_FAILURE_THRESHOLD
        recovery_seconds: float = 30.0,     # CB_RECOVERY_SECONDS
        half_open_max: int = 1,
    ):

Globally shared: _breakers: dict[str, CircuitBreaker] indexed by base URL; all ConversationSession instances share the same breaker.

4.3.3 Retry Logic ​

python
_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}

# Permanent content-policy rejections (non-retryable)
_PERMANENT_CONTENT_ERROR_CODES = frozenset({
    "local:sensitive_words",           # api3.wlai.vip Chinese content filter
    "content_filter",                  # Azure OpenAI
    "content_policy_violation",        # OpenAI
    "safety",                          # Gemini / Vertex
    "prohibited_content",              # Gemini
})

4.3.4 Streaming Decision ​

python
_REASONING_MODEL_PREFIXES = ("gpt-5", "gpt-o", "o1-", "o3-", "o4-")
_REASONING_MODEL_EXACT = frozenset({"o1", "o3", "o4"})

def _should_stream(model: str) -> bool:
    """Returns False for reasoning models (gpt-5.x, gpt-o1, gpt-o3).
    
    Reason: reasoning-model SSE delta protocol is incompatible with the
    existing assembler — HTTP 200 but prompt_tokens=0, completion_tokens=0,
    100–200s latency.
    
    Environment variable overrides:
    FAMILIARS_STREAM_LLM=true  → force enable (debugging)
    FAMILIARS_STREAM_LLM=false → force disable
    """

4.3.5 Tool Loop (contact() method) ​

python
async def contact(self, history: list[dict], **kwargs) -> dict:
    """Run the tool-call loop on top of chat().
    
    Each turn the LLM may issue ≥1 tool calls → dispatch → tool_result → continue loop.
    Until max_turns or the LLM stops issuing tool calls.
    
    Turn budget escalation: when budget is exhausted, asks the manager LLM whether to extend.
    """

4.3.6 Auto Document Refresh ​

python
def _build_auto_refresh_messages(path_norm: str, write_tool: str,
                                  progress_footer: str = "") -> list[dict]:
    """Synthesize (assistant tool_call, tool result) pair after a write operation.
    
    On the next mutation of the same file, _mark_stale_docx_reads collapses old content.
    Returns two messages ready to append to history.
    """

§4.4 DialogueManager ​

python
class DialogueManager:
    """Manages N Dialogues (dialogue trees + history).
    
    Responsibilities:
    - _load_existing_dialogues() reads from disk
    - store_dialogue(dialogue_id) persists
    - build_rag_context(query) assembles RAG context
    - release_memory() releases resident memory (JIT load on demand)
    """
    
    def __init__(self, memorizer, agent_path, top_k=5, workspace_route=""):
        self.memorizer = memorizer
        self.agent_path = agent_path
        self.top_k = top_k
        self.dialogue: list[Dialogue] = []

§4.5 Transferer (Cross-Agent Dispatch) ​

src/models/transferer.py is the single chokepoint for all cross-agent calls.

python
def dispose(request_model: str, request: str, **kwargs) -> Any:
    """Resolve the destination string to a callable.
    
    Priority:
    1. Tool client aliases — "BrowseTool_Client", "browser_tools" → instantiate ToolClient
    2. Tool method paths — "BaseTools.open_app" → resolve to src.utils.* methods
    3. Model names — send directly to chat completion (no tool schemas)
    4. Empty / "Writer" / unknown → fall back to DEFAULT_MODEL
    """

Client Registry ​

python
_CLIENT_REGISTRY: dict[str, str] = {
    "basetoolclient":     "src.models.clients.base_client:BaseToolClient",
    "browsertoolclient":  "src.models.clients.browser_client:BrowserToolClient",
    "webdive":            "src.models.clients.browser_client:WebDiveToolClient",
    "multimodaltoolclient": "src.models.clients.multimodal_client:MultimodalToolClient",
    "inputtoolclient":    "src.models.clients.input_client:InputToolClient",
    "doctoolclient":      "src.models.clients.doc_client:DocToolClient",
    "dailytoolclient":    "src.models.clients.daily_client:DailyToolClient",
    "systemtoolclient":   "src.models.clients.system_client:SystemToolClient",
    # ...
}

§4.6 Tool System Architecture ​

4.6.1 ToolDispatcher ​

python
class ToolDispatcher:
    """Tool name → callable registry.
    
    15 default tool instances:
    """
    
    _DEFAULT_INSTANCES = [
        ("BaseTools",           BaseTools),
        ("BaseTools.InputController", BaseTools.InputController),
        ("BrowserTools",        BrowserTools),
        ("MultimodalTools",     MultimodalTools),
        ("DailyTools",          DailyTools),
        ("DailyMonitorTools",   DailyMonitorTools),
        ("DocTools",            DocTools),
        ("MemoryTools",         MemoryTools),
        ("SystemTools",         SystemTools),
        ("UserTools",           UserTools),
        ("UserSecretTools",     UserSecretTools),
        ("PhoneTools",          PhoneTools),
        ("SkillTools",          SkillTools),
        ("SkillWatcherTools",   SkillWatcherTools),
        ("BrokerTools",         BrokerTools),
    ]

4.6.2 execute_skill Pseudo-Tool ​

python
def execute_skill_schema() -> dict:
    """OpenAI function schema for execute_skill pseudo-tool.
    
    Framework-level affordance, registered on every ToolDispatcher.
    Recipe / flow units expose this schema via tools.whitelist.
    """
    return {
        "type": "function",
        "function": {
            "name": "execute_skill",
            "parameters": {
                "type": "object",
                "properties": {
                    "skill_id": {"type": "string"},
                    "args": {"type": "object"},
                },
                "required": ["skill_id"],
            },
        },
    }

4.6.3 Tool Activity Mapping ​

python
_INSTANCE_ACTIVITY_KEY: dict[str, str] = {
    "BaseTools":         "base",
    "BrowserTools":      "browser",
    "MultimodalTools":   "multimodal",
    "DailyTools":        "daily",
    "DailyMonitorTools": "daily",
    "DocTools":          "doc",
    "SystemTools":       "system",
    "InputController":   "input",
}

Drives the frontend robot-mascot status transitions.

4.6.4 Client Tool Whitelists ​

Each ToolClient subclass exposes only the tools it can use:

ClientTool subset
BaseToolClientBase tools (read / write / execute / find / grep / download)
BrowserToolClientBrowser tools + explore_page
MultimodalToolClientVision + operation tools
DocToolClientDocument tools (Word / Excel / PPT / PDF)
DailyToolClientSchedule / todo / reminder
SystemToolClientSystem tools

Schemas come from workspace/tools.json + filtered_schemas() filters per client.

§4.7 Turn Budget Escalator ​

python
# max_turns is no longer a hard cap
# When budget is exhausted, asks the manager LLM:
#   - Extend (+N turns)
#   - Abort

# Implementation: _turn_budget_escalator calls MANAGER_MODEL to decide

Next: 04 - RAG & Memory →

Familiars · 多 Agent AI 桌面应用