Skip to content

API Reference

Auto-generated from source docstrings via mkdocstrings. The shared framework lives in semos-agentura-core; each agent is a thin BaseAgentService subclass.

Core framework (semos-agentura-core)

BaseAgentService

Abstract base every agent implements. Declares the tools and skills an agent exposes and serves them over both MCP and A2A.

Bases: ABC

Abstract base for all agent services.

Subclass this and implement the abstract properties/methods. The transport layer reads these to wire up MCP + A2A automatically.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
class BaseAgentService(ABC):
    """Abstract base for all agent services.

    Subclass this and implement the abstract properties/methods.
    The transport layer reads these to wire up MCP + A2A automatically.
    """

    # Set by create_app() - available after app is built
    output_dir: Path | None = None
    base_url: str | None = None

    def file_url(self, filename: str) -> str:
        """Return the download URL for a file in the output directory."""
        from urllib.parse import quote

        return f"{self.base_url}/files/{quote(filename)}"

    def resolve_file(
        self,
        source: str,
        default_ext: str = ".bin",
        filename: str = "",
    ) -> Path:
        """Resolve a source string to a local file Path.

        Detects the format first, then resolves:
        1. data URI (data:mime;base64,...) -> decode and write to temp file
        2. raw base64 -> decode and write to temp file
        3. HTTP(S) URL -> fetch and write to temp file
        4. file path -> return as-is if it exists

        If filename is provided, the temp file preserves that name
        (important for downstream tools that infer type from name).
        """
        assert self.output_dir, "output_dir must be set before resolving files"

        # 1. data URI
        if source.startswith("data:"):
            _, _, encoded = source.partition(",")
            return self._write_decoded(encoded, default_ext, filename)

        # 2. HTTP(S) URL
        if source.startswith(("http://", "https://")):
            import httpx as _httpx

            resp = _httpx.get(source, timeout=30)
            resp.raise_for_status()
            ext = Path(source.rsplit("/", 1)[-1]).suffix or default_ext
            fn = filename or f"_fetch_{uuid.uuid4().hex[:8]}{ext}"
            tmp = self.output_dir / fn
            tmp.write_bytes(resp.content)
            return tmp

        # 3. Existing file path (only try for short strings that look like paths)
        if len(source) < 1024:
            p = Path(source)
            if p.exists():
                return p
            # Try relative to output_dir
            p2 = self.output_dir / source
            if p2.exists():
                return p2

        # 4. Raw base64 (no data: prefix)
        try:
            data = base64.b64decode(source, validate=True)
            if len(data) > 4:
                return self._write_bytes(data, default_ext, filename)
        except Exception:
            pass

        # Fallback: return as path (may not exist - tool will error)
        return Path(source)

    def _write_decoded(
        self,
        encoded: str,
        default_ext: str,
        filename: str,
    ) -> Path:
        """Decode base64 and write to a temp file."""
        data = base64.b64decode(encoded)
        return self._write_bytes(data, default_ext, filename)

    def _write_bytes(
        self,
        data: bytes,
        default_ext: str,
        filename: str,
    ) -> Path:
        """Write bytes to a temp file, preserving filename if given."""
        if filename:
            subdir = self.output_dir / f"_att_{uuid.uuid4().hex[:8]}"
            subdir.mkdir(exist_ok=True)
            tmp = subdir / filename
            # Create parent dirs for nested names (e.g. "images/cat.png")
            tmp.parent.mkdir(parents=True, exist_ok=True)
        else:
            tmp = self.output_dir / f"_upload_{uuid.uuid4().hex[:8]}{default_ext}"
        tmp.write_bytes(data)
        return tmp

    def resolve_file_attachment(
        self,
        source: FileAttachment | str,
        default_ext: str = ".bin",
    ) -> Path:
        """Resolve a FileAttachment dict or plain string to a local Path."""
        if isinstance(source, dict):
            name = source.get("name", "")
            content = source.get("content", name)
            ext = Path(name).suffix if name else default_ext
            return self.resolve_file(content, default_ext=ext, filename=name)
        return self.resolve_file(source, default_ext=default_ext)

    @property
    @abstractmethod
    def agent_name(self) -> str:
        """Human-readable agent name (e.g. 'Email Agent')."""

    @property
    @abstractmethod
    def agent_description(self) -> str:
        """Short description of what this agent does."""

    @property
    def agent_version(self) -> str:
        return "0.1.0"

    @property
    def agent_system_prompt(self) -> str:
        """Agent-specific system prompt, appended to LLMExecutor's base prompt.

        Default: current date/time. Override in subclasses:
        - Extend: return super().agent_system_prompt + "\\n\\nExtra instructions.".
        - Replace: return "Custom prompt." (omits date/time).
        """
        from datetime import datetime

        now = datetime.now().astimezone()
        tz = now.strftime("%Z") or str(now.tzinfo)
        return now.strftime(f"Current date/time: %Y-%m-%d %H:%M (%A) {tz}.")

    # Optional LLM router for A2A natural language dispatch.
    # Set ROUTER_LLM_MODEL, ROUTER_LLM_API_KEY, ROUTER_LLM_API_BASE
    # in .env to enable. Override in subclass for custom logic.
    @property
    def router_llm_model(self) -> str:
        return os.environ.get("ROUTER_LLM_MODEL", "")

    @property
    def router_llm_api_key(self) -> str:
        return os.environ.get("ROUTER_LLM_API_KEY", "")

    @property
    def router_llm_api_base(self) -> str:
        return os.environ.get("ROUTER_LLM_API_BASE", "")

    @abstractmethod
    def get_tools(self) -> list[AgentTool]:
        """Return all MCP tools this agent exposes."""

    @abstractmethod
    def get_skills(self) -> list[SkillDef]:
        """Return all A2A skills this agent exposes."""

    @abstractmethod
    async def execute_skill(
        self,
        skill_id: str,
        message: str,
        *,
        task_id: str | None = None,
    ) -> str:
        """Execute an A2A skill by ID with the given message."""

agent_description abstractmethod property

Short description of what this agent does.

agent_name abstractmethod property

Human-readable agent name (e.g. 'Email Agent').

agent_system_prompt property

Agent-specific system prompt, appended to LLMExecutor's base prompt.

Default: current date/time. Override in subclasses: - Extend: return super().agent_system_prompt + "\n\nExtra instructions.". - Replace: return "Custom prompt." (omits date/time).

execute_skill(skill_id, message, *, task_id=None) abstractmethod async

Execute an A2A skill by ID with the given message.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
@abstractmethod
async def execute_skill(
    self,
    skill_id: str,
    message: str,
    *,
    task_id: str | None = None,
) -> str:
    """Execute an A2A skill by ID with the given message."""

file_url(filename)

Return the download URL for a file in the output directory.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
def file_url(self, filename: str) -> str:
    """Return the download URL for a file in the output directory."""
    from urllib.parse import quote

    return f"{self.base_url}/files/{quote(filename)}"

get_skills() abstractmethod

Return all A2A skills this agent exposes.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
@abstractmethod
def get_skills(self) -> list[SkillDef]:
    """Return all A2A skills this agent exposes."""

get_tools() abstractmethod

Return all MCP tools this agent exposes.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
@abstractmethod
def get_tools(self) -> list[AgentTool]:
    """Return all MCP tools this agent exposes."""

resolve_file(source, default_ext='.bin', filename='')

Resolve a source string to a local file Path.

Detects the format first, then resolves: 1. data URI (data:mime;base64,...) -> decode and write to temp file 2. raw base64 -> decode and write to temp file 3. HTTP(S) URL -> fetch and write to temp file 4. file path -> return as-is if it exists

If filename is provided, the temp file preserves that name (important for downstream tools that infer type from name).

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
def resolve_file(
    self,
    source: str,
    default_ext: str = ".bin",
    filename: str = "",
) -> Path:
    """Resolve a source string to a local file Path.

    Detects the format first, then resolves:
    1. data URI (data:mime;base64,...) -> decode and write to temp file
    2. raw base64 -> decode and write to temp file
    3. HTTP(S) URL -> fetch and write to temp file
    4. file path -> return as-is if it exists

    If filename is provided, the temp file preserves that name
    (important for downstream tools that infer type from name).
    """
    assert self.output_dir, "output_dir must be set before resolving files"

    # 1. data URI
    if source.startswith("data:"):
        _, _, encoded = source.partition(",")
        return self._write_decoded(encoded, default_ext, filename)

    # 2. HTTP(S) URL
    if source.startswith(("http://", "https://")):
        import httpx as _httpx

        resp = _httpx.get(source, timeout=30)
        resp.raise_for_status()
        ext = Path(source.rsplit("/", 1)[-1]).suffix or default_ext
        fn = filename or f"_fetch_{uuid.uuid4().hex[:8]}{ext}"
        tmp = self.output_dir / fn
        tmp.write_bytes(resp.content)
        return tmp

    # 3. Existing file path (only try for short strings that look like paths)
    if len(source) < 1024:
        p = Path(source)
        if p.exists():
            return p
        # Try relative to output_dir
        p2 = self.output_dir / source
        if p2.exists():
            return p2

    # 4. Raw base64 (no data: prefix)
    try:
        data = base64.b64decode(source, validate=True)
        if len(data) > 4:
            return self._write_bytes(data, default_ext, filename)
    except Exception:
        pass

    # Fallback: return as path (may not exist - tool will error)
    return Path(source)

resolve_file_attachment(source, default_ext='.bin')

Resolve a FileAttachment dict or plain string to a local Path.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
def resolve_file_attachment(
    self,
    source: FileAttachment | str,
    default_ext: str = ".bin",
) -> Path:
    """Resolve a FileAttachment dict or plain string to a local Path."""
    if isinstance(source, dict):
        name = source.get("name", "")
        content = source.get("content", name)
        ext = Path(name).suffix if name else default_ext
        return self.resolve_file(content, default_ext=ext, filename=name)
    return self.resolve_file(source, default_ext=default_ext)

LLMExecutor

The universal agentic loop: takes a message plus tool definitions, drives the model/tool cycle, and emits an ExecutorResult.

Multi-step LLM tool-calling loop.

Supports Anthropic (native + Azure AI Foundry) and OpenAI-compatible APIs. Injects 5 synthetic tools for task lifecycle control.

Source code in packages/semos-agentura-core/src/semos/agentura/core/llm_executor.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
class LLMExecutor:
    """Multi-step LLM tool-calling loop.

    Supports Anthropic (native + Azure AI Foundry) and OpenAI-compatible APIs.
    Injects 5 synthetic tools for task lifecycle control.
    """

    def __init__(
        self,
        tools: list[AgentTool],
        model: str,
        api_key: str,
        api_base: str,
        system_prompt: str = "",
        max_steps: int = 10,
        on_progress: Any | None = None,
        max_tokens: int = 32000,
    ):
        self.tools = tools
        self.model = model
        self.api_key = api_key
        self.api_base = api_base
        base = self._synthetic_tools_prompt()
        self.system_prompt = f"{base}\n\n{system_prompt}" if system_prompt else base
        self.max_steps = max_steps
        # Per-turn output budget. Higher = the model can emit a full document
        # in one write_file call instead of fragmenting into many small files.
        self.max_tokens = max_tokens
        self.on_progress = on_progress  # async callback(message: str)
        self._provider = _detect_provider(api_base)
        self._tool_map = {t.name: t for t in tools}
        self._produced_files: list[dict] = []

    @staticmethod
    def _synthetic_tools_prompt() -> str:
        """Instructions for synthetic task-lifecycle tools. Always included."""
        return (
            "You MUST use the provided tools to "
            "complete tasks. Do NOT describe what you would do - execute "
            "it by calling the appropriate tool. After getting a tool "
            "result, either call another tool or provide your final "
            "answer as plain text. Use _request_input if you need "
            "clarification. Use _reject_task if the task is outside "
            "your capabilities. Use _return_result to provide your "
            "final answer with specific files."
        )

    async def run(
        self,
        message: str,
        files: list[dict] | None = None,
        history: list[dict] | None = None,
    ) -> ExecutorResult:
        """Run the agentic loop.

        Args:
            message: User/requester message text.
            files: FileAttachment dicts [{name, content}] for inline files.
            history: Prior conversation messages for continuation.

        Returns:
            ExecutorResult with text, files, status, and history.
        """
        self._produced_files = []
        self.last_messages: list[dict] = []  # exposed for cancel recovery
        progress_updates: list[str] = []

        # Build conversation
        messages = list(history or [])
        user_content = message
        if files:
            file_names = [f.get("name", "file") for f in files]
            user_content += f"\n\n[Attached files: {', '.join(file_names)}]"
        messages.append({"role": "user", "content": user_content})
        self.last_messages = messages  # live ref for cancel recovery

        for step in range(self.max_steps):
            try:
                force_tool = step == 0 and not history
                response = await self._call_llm(
                    messages,
                    force_tool=force_tool,
                )
            except Exception as e:
                logger.exception("LLM call failed at step %d", step)
                return ExecutorResult(
                    text=f"LLM error: {e}",
                    status="failed",
                    history=messages,
                )

            # Parse response
            text_parts, tool_calls = self._parse_response(response)

            if not tool_calls:
                # LLM produced text only - done
                final_text = "\n".join(text_parts)
                messages.append({"role": "assistant", "content": final_text})
                return ExecutorResult(
                    text=final_text,
                    files=self._produced_files,
                    status="completed",
                    progress_updates=progress_updates,
                    history=messages,
                )

            # Append assistant message with tool calls
            messages.append(self._assistant_message(text_parts, tool_calls))

            # Split into synthetic (sequential) and real (parallel) tools
            synthetic = [tc for tc in tool_calls if tc.name in _SYNTHETIC_TOOLS]
            real = [tc for tc in tool_calls if tc.name not in _SYNTHETIC_TOOLS]

            # Handle synthetic tools first (may return early)
            for tc in synthetic:
                result = self._handle_synthetic(
                    tc,
                    messages,
                    progress_updates,
                )
                if result is not None:
                    result.progress_updates = progress_updates
                    return result
                messages.append(self._tool_result_message(tc.id, "Progress noted."))

            # Execute real tools in parallel
            if real:
                results = await asyncio.gather(
                    *(self._execute_tool(tc.name, tc.arguments, files) for tc in real),
                    return_exceptions=True,
                )
                for tc, tool_result in zip(real, results, strict=True):
                    if isinstance(tool_result, BaseException):
                        tool_result = f"Error executing {tc.name}: {tool_result}"
                    elif not isinstance(tool_result, str):
                        tool_result = str(tool_result)
                    messages.append(self._tool_result_message(tc.id, tool_result))

        # Max steps reached
        final_text = "Task partially completed (max steps reached)."
        return ExecutorResult(
            text=final_text,
            files=self._produced_files,
            status="completed",
            history=messages,
            progress_updates=progress_updates,
        )

    def _handle_synthetic(
        self,
        tc: _ToolCall,
        messages: list[dict],
        progress_updates: list[str],
    ) -> ExecutorResult | None:
        """Handle a synthetic tool call. Returns result or None to continue."""
        args = tc.arguments

        if tc.name == TOOL_RETURN_RESULT:
            text = args.get("message", "")
            requested_files = args.get("files")
            if requested_files:
                files = [f for f in self._produced_files if f.get("filename") in requested_files]
            else:
                files = self._produced_files
            # Append tool_result so history is API-valid for continuation
            messages.append(self._tool_result_message(tc.id, "Result delivered."))
            return ExecutorResult(
                text=text,
                files=files,
                status="completed",
                history=messages,
            )

        if tc.name == TOOL_REQUEST_INPUT:
            question = args.get("question", "")
            # Append tool_result so history is valid for continuation
            messages.append(self._tool_result_message(tc.id, "Waiting for input."))
            return ExecutorResult(
                text=question,
                files=self._produced_files,
                status="input_required",
                question=question,
                history=messages,
            )

        if tc.name == TOOL_REJECT_TASK:
            reason = args.get("reason", "Task rejected")
            messages.append(self._tool_result_message(tc.id, "Task rejected."))
            return ExecutorResult(
                text=reason,
                status="rejected",
                history=messages,
            )

        if tc.name == TOOL_REQUEST_AUTH:
            messages.append(self._tool_result_message(tc.id, "Waiting for auth."))
            return ExecutorResult(
                text=args.get("message", "Authentication required"),
                status="auth_required",
                auth_scheme=args.get("scheme", ""),
                history=messages,
            )

        if tc.name == TOOL_REPORT_PROGRESS:
            msg = args.get("message", "")
            progress_updates.append(msg)
            if self.on_progress:
                import asyncio

                if asyncio.iscoroutinefunction(self.on_progress):
                    asyncio.ensure_future(self.on_progress(msg))
                else:
                    self.on_progress(msg)
            return None  # continue loop

        return None

    async def _execute_tool(
        self,
        name: str,
        arguments: dict,
        files: list[dict] | None,
    ) -> str:
        """Execute a real tool and return result as string."""
        td = self._tool_map.get(name)
        if not td:
            return f"Error: unknown tool '{name}'"

        file_params = getattr(td, "resolved_file_params", [])

        # Inject file content into file params.
        if file_params:
            from .file_middleware import strip_vfs_prefix

            by_name: dict[str, str] = {}
            for f in files or []:
                name = f.get("name", "")
                content = f.get("content", "")
                if name and content:
                    by_name[strip_vfs_prefix(name)] = content
            for pf in self._produced_files:
                fn = pf.get("filename", "")
                url = pf.get("download_url", "")
                if fn and url:
                    by_name[strip_vfs_prefix(fn)] = url
            for param in file_params:
                val = arguments.get(param)
                if isinstance(val, str):
                    key = strip_vfs_prefix(val)
                    # Also try basename for path-like values
                    bare = key.rsplit("/", 1)[-1]
                    match_key = key if key in by_name else bare if bare in by_name else None
                    if match_key:
                        fa = {"name": match_key, "content": by_name[match_key]}
                        raw = _raw_tool_schema(td)
                        if _param_is_array(raw, param):
                            arguments[param] = [fa]
                        else:
                            arguments[param] = fa
                    else:
                        logger.warning(
                            "File param %s=%r not in files: %s",
                            param,
                            val,
                            list(by_name.keys()),
                        )

        # Validate input against the raw JSON schema. This preserves
        # oneOf/anyOf/const/enum (a generated Pydantic model flattens
        # combinators to Any and validates nothing useful).
        raw_schema = _raw_tool_schema(td)
        if raw_schema.get("properties"):
            try:
                jsonschema.validate(arguments, raw_schema)
            except jsonschema.ValidationError as e:
                return f"Validation error for {name}: {e.message}"

        try:
            result = await td._arun(**arguments)
        except Exception as e:
            logger.warning("Tool %s failed: %s", name, e)
            return f"Error executing {name}: {e}"

        return self._process_tool_output(result)

    def _process_tool_output(self, result: Any) -> str:
        """Convert tool output to string and track produced files."""
        from pathlib import Path as PathType

        from .base import NamedFile, ToolResult

        if result is None:
            return ""

        # ToolResult: text + data + files
        if isinstance(result, ToolResult):
            parts = []
            if result.text:
                parts.append(result.text)
            if result.data:
                parts.append(json.dumps(result.data, ensure_ascii=False))
            for f in result.files:
                meta = self._track_file(f)
                if meta:
                    parts.append(json.dumps(meta, ensure_ascii=False))
            return "\n".join(parts) if parts else ""

        # NamedFile
        if isinstance(result, NamedFile):
            meta = self._track_file(result)
            return json.dumps(meta, ensure_ascii=False) if meta else result.name

        # Path
        if isinstance(result, PathType) and result.exists():
            meta = self._track_file(result)
            return json.dumps(meta, ensure_ascii=False) if meta else result.name

        # String (may be JSON with legacy download_url)
        text = str(result)
        try:
            data = json.loads(text)
            if isinstance(data, dict) and "download_url" in data:
                self._produced_files.append(data)
        except (json.JSONDecodeError, TypeError):
            pass
        return text

    def _track_file(self, f: Any) -> dict | None:
        """Track a produced file and return metadata dict."""
        import mimetypes
        from pathlib import Path as PathType

        from .base import NamedFile

        if isinstance(f, NamedFile):
            path, name = f.path, f.name
        elif isinstance(f, PathType):
            path, name = f, f.name
        else:
            return None
        mime, _ = mimetypes.guess_type(str(path))
        meta = {
            "filename": name,
            "download_url": f"files/{path.name}",
            "mime_type": mime or "application/octet-stream",
            "size_bytes": path.stat().st_size if path.exists() else 0,
        }
        self._produced_files.append(meta)
        return meta

    # LLM API calls

    async def _call_llm(
        self,
        messages: list[dict],
        *,
        force_tool: bool = False,
    ) -> dict:
        if self._provider in ("azure_anthropic", "anthropic"):
            return await self._call_anthropic(
                messages,
                force_tool=force_tool,
            )
        return await self._call_openai(
            messages,
            force_tool=force_tool,
        )

    async def _call_anthropic(
        self,
        messages: list[dict],
        *,
        force_tool: bool = False,
    ) -> dict:
        url = f"{self.api_base.rstrip('/')}/v1/messages"
        headers: dict[str, str] = {
            "Content-Type": "application/json",
            "anthropic-version": "2023-06-01",
        }
        if self._provider == "azure_anthropic":
            headers["Authorization"] = f"Bearer {self.api_key}"
        else:
            headers["x-api-key"] = self.api_key

        # Build tool list: real + synthetic
        tools = [_tool_schema(t) for t in self.tools]
        tools.extend(_synthetic_tool_schemas_anthropic())

        # Separate system from messages
        api_messages = [m for m in messages if m.get("role") != "system"]

        payload: dict[str, Any] = {
            "model": self.model,
            "max_tokens": self.max_tokens,
            "system": self.system_prompt,
            "tools": tools,
            "messages": api_messages,
        }
        if force_tool:
            payload["tool_choice"] = {"type": "any"}

        async with httpx.AsyncClient() as client:
            resp = await client.post(
                url,
                headers=headers,
                json=payload,
                timeout=300.0,
            )
            if resp.status_code >= 400:
                raise RuntimeError(f"Anthropic API error {resp.status_code}: {resp.text[:300]}")
            return resp.json()

    async def _call_openai(
        self,
        messages: list[dict],
        *,
        force_tool: bool = False,
    ) -> dict:
        url = f"{self.api_base.rstrip('/')}/chat/completions"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.api_key}",
        }

        tools = [_tool_schema_openai(t) for t in self.tools]
        tools.extend(_synthetic_tool_schemas_openai())

        api_messages = [
            {"role": "system", "content": self.system_prompt},
        ]
        api_messages.extend(messages)

        payload: dict[str, Any] = {
            "model": self.model,
            "max_tokens": self.max_tokens,
            "tools": tools,
            "messages": api_messages,
        }
        if force_tool:
            payload["tool_choice"] = "required"

        async with httpx.AsyncClient() as client:
            resp = await client.post(
                url,
                headers=headers,
                json=payload,
                timeout=300.0,
            )
            if resp.status_code >= 400:
                raise RuntimeError(f"OpenAI API error {resp.status_code}: {resp.text[:300]}")
            return resp.json()

    # Response parsing

    def _parse_response(
        self,
        response: dict,
    ) -> tuple[list[str], list[_ToolCall]]:
        """Parse LLM response into text parts and tool calls."""
        if self._provider in ("azure_anthropic", "anthropic"):
            return self._parse_anthropic(response)
        return self._parse_openai(response)

    def _parse_anthropic(
        self,
        response: dict,
    ) -> tuple[list[str], list[_ToolCall]]:
        texts = []
        calls = []
        for block in response.get("content", []):
            if block.get("type") == "text":
                texts.append(block["text"])
            elif block.get("type") == "tool_use":
                calls.append(
                    _ToolCall(
                        id=block["id"],
                        name=block["name"],
                        arguments=block.get("input", {}),
                    )
                )
        return texts, calls

    def _parse_openai(
        self,
        response: dict,
    ) -> tuple[list[str], list[_ToolCall]]:
        texts = []
        calls = []
        choices = response.get("choices", [])
        if not choices:
            return texts, calls
        msg = choices[0].get("message", {})
        if msg.get("content"):
            texts.append(msg["content"])
        for tc in msg.get("tool_calls", []):
            fn = tc.get("function", {})
            try:
                args = json.loads(fn.get("arguments", "{}"))
            except json.JSONDecodeError:
                args = {}
            calls.append(
                _ToolCall(
                    id=tc.get("id", ""),
                    name=fn.get("name", ""),
                    arguments=args,
                )
            )
        return texts, calls

    # Message construction

    def _assistant_message(
        self,
        text_parts: list[str],
        tool_calls: list[_ToolCall],
    ) -> dict:
        """Build assistant message with tool calls."""
        if self._provider in ("azure_anthropic", "anthropic"):
            content = []
            for t in text_parts:
                content.append({"type": "text", "text": t})
            for tc in tool_calls:
                content.append(
                    {
                        "type": "tool_use",
                        "id": tc.id,
                        "name": tc.name,
                        "input": tc.arguments,
                    }
                )
            return {"role": "assistant", "content": content}
        # OpenAI format
        msg: dict[str, Any] = {"role": "assistant"}
        if text_parts:
            msg["content"] = "\n".join(text_parts)
        if tool_calls:
            msg["tool_calls"] = [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {
                        "name": tc.name,
                        "arguments": json.dumps(tc.arguments),
                    },
                }
                for tc in tool_calls
            ]
        return msg

    def _tool_result_message(self, tool_call_id: str, result: str) -> dict:
        """Build tool result message."""
        if self._provider in ("azure_anthropic", "anthropic"):
            return {
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": tool_call_id,
                        "content": result,
                    }
                ],
            }
        return {
            "role": "tool",
            "tool_call_id": tool_call_id,
            "content": result,
        }

run(message, files=None, history=None) async

Run the agentic loop.

Parameters:

Name Type Description Default
message str

User/requester message text.

required
files list[dict] | None

FileAttachment dicts [{name, content}] for inline files.

None
history list[dict] | None

Prior conversation messages for continuation.

None

Returns:

Type Description
ExecutorResult

ExecutorResult with text, files, status, and history.

Source code in packages/semos-agentura-core/src/semos/agentura/core/llm_executor.py
async def run(
    self,
    message: str,
    files: list[dict] | None = None,
    history: list[dict] | None = None,
) -> ExecutorResult:
    """Run the agentic loop.

    Args:
        message: User/requester message text.
        files: FileAttachment dicts [{name, content}] for inline files.
        history: Prior conversation messages for continuation.

    Returns:
        ExecutorResult with text, files, status, and history.
    """
    self._produced_files = []
    self.last_messages: list[dict] = []  # exposed for cancel recovery
    progress_updates: list[str] = []

    # Build conversation
    messages = list(history or [])
    user_content = message
    if files:
        file_names = [f.get("name", "file") for f in files]
        user_content += f"\n\n[Attached files: {', '.join(file_names)}]"
    messages.append({"role": "user", "content": user_content})
    self.last_messages = messages  # live ref for cancel recovery

    for step in range(self.max_steps):
        try:
            force_tool = step == 0 and not history
            response = await self._call_llm(
                messages,
                force_tool=force_tool,
            )
        except Exception as e:
            logger.exception("LLM call failed at step %d", step)
            return ExecutorResult(
                text=f"LLM error: {e}",
                status="failed",
                history=messages,
            )

        # Parse response
        text_parts, tool_calls = self._parse_response(response)

        if not tool_calls:
            # LLM produced text only - done
            final_text = "\n".join(text_parts)
            messages.append({"role": "assistant", "content": final_text})
            return ExecutorResult(
                text=final_text,
                files=self._produced_files,
                status="completed",
                progress_updates=progress_updates,
                history=messages,
            )

        # Append assistant message with tool calls
        messages.append(self._assistant_message(text_parts, tool_calls))

        # Split into synthetic (sequential) and real (parallel) tools
        synthetic = [tc for tc in tool_calls if tc.name in _SYNTHETIC_TOOLS]
        real = [tc for tc in tool_calls if tc.name not in _SYNTHETIC_TOOLS]

        # Handle synthetic tools first (may return early)
        for tc in synthetic:
            result = self._handle_synthetic(
                tc,
                messages,
                progress_updates,
            )
            if result is not None:
                result.progress_updates = progress_updates
                return result
            messages.append(self._tool_result_message(tc.id, "Progress noted."))

        # Execute real tools in parallel
        if real:
            results = await asyncio.gather(
                *(self._execute_tool(tc.name, tc.arguments, files) for tc in real),
                return_exceptions=True,
            )
            for tc, tool_result in zip(real, results, strict=True):
                if isinstance(tool_result, BaseException):
                    tool_result = f"Error executing {tc.name}: {tool_result}"
                elif not isinstance(tool_result, str):
                    tool_result = str(tool_result)
                messages.append(self._tool_result_message(tc.id, tool_result))

    # Max steps reached
    final_text = "Task partially completed (max steps reached)."
    return ExecutorResult(
        text=final_text,
        files=self._produced_files,
        status="completed",
        history=messages,
        progress_updates=progress_updates,
    )

Tools

AgentTool / the agent_tool decorator define callable tools; ToolResult is the normalized, multi-modal return type (text + data + files).

Bases: BaseTool

Tool with MCP annotations, file param auto-detection, and Pydantic validation.

Subclass this for each tool, or use the @agent_tool decorator for simple cases.

Example (subclass): class DigestInput(BaseModel): source: FileAttachment | str = Field(description="Document to digest")

class DigestDocumentTool(AgentTool):
    name: str = "digest_document"
    description: str = "Digest a document into Markdown."
    args_schema: type[BaseModel] = DigestInput
    read_only: bool = True

    async def _arun(self, **kwargs) -> str:
        ...

Example (decorator): @agent_tool(read_only=True) async def get_examples(category: str = "") -> str: """List available examples.""" ...

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
class AgentTool(BaseTool):
    """Tool with MCP annotations, file param auto-detection, and Pydantic validation.

    Subclass this for each tool, or use the @agent_tool decorator for simple cases.

    Example (subclass):
        class DigestInput(BaseModel):
            source: FileAttachment | str = Field(description="Document to digest")

        class DigestDocumentTool(AgentTool):
            name: str = "digest_document"
            description: str = "Digest a document into Markdown."
            args_schema: type[BaseModel] = DigestInput
            read_only: bool = True

            async def _arun(self, **kwargs) -> str:
                ...

    Example (decorator):
        @agent_tool(read_only=True)
        async def get_examples(category: str = "") -> str:
            \"\"\"List available examples.\"\"\"
            ...
    """

    # MCP annotations (hints for client behavior)
    read_only: bool = False
    destructive: bool = False
    idempotent: bool = False
    # MCP execution hints
    task_support: str | None = None
    # File params - None = auto-detect from args_schema, [] = explicitly none
    file_params: list[str] | None = None
    # Override for dynamic schemas (e.g., add_root with runtime-detected protocols)
    parameters_override: dict[str, Any] | None = None

    # Service reference (set by bind_service)
    _service: Any = PrivateAttr(default=None)

    def bind_service(self, service: BaseAgentService) -> AgentTool:
        """Bind this tool to a service instance. Returns self for chaining."""
        self._service = service
        return self

    @property
    def resolved_file_params(self) -> list[str]:
        """File parameter names, auto-detected if not explicitly set."""
        if self.file_params is not None:
            return self.file_params
        return _detect_file_params(self.args_schema)

    def get_input_schema(self) -> dict[str, Any]:
        """Return JSON Schema for this tool's input parameters.

        Uses parameters_override if set, otherwise generates from args_schema.
        """
        if self.parameters_override is not None:
            return self.parameters_override
        if self.args_schema is not None:
            schema = self.args_schema.model_json_schema()
            return schema
        return {"type": "object", "properties": {}}

    def _run(self, *args: Any, **kwargs: Any) -> Any:
        raise NotImplementedError("Use async _arun()")

    async def _arun(self, *args: Any, **kwargs: Any) -> Any:
        raise NotImplementedError("Subclass must implement _arun()")

resolved_file_params property

File parameter names, auto-detected if not explicitly set.

bind_service(service)

Bind this tool to a service instance. Returns self for chaining.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
def bind_service(self, service: BaseAgentService) -> AgentTool:
    """Bind this tool to a service instance. Returns self for chaining."""
    self._service = service
    return self

get_input_schema()

Return JSON Schema for this tool's input parameters.

Uses parameters_override if set, otherwise generates from args_schema.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
def get_input_schema(self) -> dict[str, Any]:
    """Return JSON Schema for this tool's input parameters.

    Uses parameters_override if set, otherwise generates from args_schema.
    """
    if self.parameters_override is not None:
        return self.parameters_override
    if self.args_schema is not None:
        schema = self.args_schema.model_json_schema()
        return schema
    return {"type": "object", "properties": {}}

Decorator to create an AgentTool from an async function.

Usage

@agent_tool(read_only=True) async def search_emails(query: str, limit: int = 50) -> str: """Search emails matching a query.""" ...

The tool's name defaults to the function name. Description comes from the docstring. args_schema is auto-generated from the function signature.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
def agent_tool(
    _fn: Any = None,
    *,
    read_only: bool = False,
    destructive: bool = False,
    idempotent: bool = False,
    task_support: str | None = None,
    file_params: list[str] | None = None,
    name: str | None = None,
) -> Any:
    """Decorator to create an AgentTool from an async function.

    Usage:
        @agent_tool(read_only=True)
        async def search_emails(query: str, limit: int = 50) -> str:
            \"\"\"Search emails matching a query.\"\"\"
            ...

    The tool's name defaults to the function name. Description comes from
    the docstring. args_schema is auto-generated from the function signature.
    """
    from langchain_core.tools import StructuredTool

    def _wrap(fn: Any) -> AgentTool:
        tool_name = name or fn.__name__
        description = (fn.__doc__ or "").strip().split("\n")[0]

        # Create a StructuredTool to get the auto-generated args_schema
        st = StructuredTool.from_function(
            func=fn,
            name=tool_name,
            description=description,
            coroutine=fn,
        )

        class _DecoratedTool(AgentTool):
            pass

        # Build the tool instance
        tool = _DecoratedTool(
            name=tool_name,
            description=description,
            args_schema=st.args_schema,
            read_only=read_only,
            destructive=destructive,
            idempotent=idempotent,
            task_support=task_support,
            file_params=file_params,
        )

        # Store the original function for _arun
        _original_fn = fn

        async def _arun_impl(**kwargs: Any) -> Any:
            return await _original_fn(**kwargs)

        tool._arun = _arun_impl  # type: ignore[assignment]
        return tool

    if _fn is not None:
        return _wrap(_fn)
    return _wrap

Structured result from a tool function.

Tool functions can return any Python type - the MCP wrapper normalizes it automatically: str -> ToolResult(text=...) dict/list -> ToolResult(data=...) Path -> ToolResult(files=[...]) NamedFile -> ToolResult(files=[...]) ToolResult -> pass through

Use ToolResult directly only when returning multiple modalities (e.g., text + files, or data + files).

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
@dataclass
class ToolResult:
    """Structured result from a tool function.

    Tool functions can return any Python type - the MCP wrapper
    normalizes it automatically:
        str        -> ToolResult(text=...)
        dict/list  -> ToolResult(data=...)
        Path       -> ToolResult(files=[...])
        NamedFile  -> ToolResult(files=[...])
        ToolResult -> pass through

    Use ToolResult directly only when returning multiple modalities
    (e.g., text + files, or data + files).
    """

    text: str = ""
    data: dict | list | None = None
    files: list[Path | NamedFile] = field(default_factory=list)

SkillDef

A2A skill definition advertised on the agent card.

Definition of a skill that the agent exposes via A2A.

Source code in packages/semos-agentura-core/src/semos/agentura/core/base.py
@dataclass
class SkillDef:
    """Definition of a skill that the agent exposes via A2A."""

    id: str
    name: str
    description: str
    tags: list[str] = field(default_factory=list)
    examples: list[str] = field(default_factory=list)

AgenturaClient

Reference client for both MCP tools and A2A task delegation across agents.

Headless MCP client with symmetric file middleware.

LLM only sees symbolic filenames. Middleware resolves file content on input and fetches/registers produced files on output.

Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
class AgenturaClient:
    """Headless MCP client with symmetric file middleware.

    LLM only sees symbolic filenames. Middleware resolves file content
    on input and fetches/registers produced files on output.
    """

    def __init__(
        self,
        agents: dict[str, str],
        download_dir: Path | None = None,
    ) -> None:
        """
        Args:
            agents: Mapping of agent name to base URL.
                e.g. {"document": "http://localhost:8002"}
            download_dir: Directory for downloaded files.
                Defaults to a temp directory.
        """
        self._agent_urls = agents
        connections = [
            AgentConnection(
                name=name,
                url=f"{url}/mcp/sse",
                base_url=url.rstrip("/"),
            )
            for name, url in agents.items()
        ]
        self.hub = MCPHub(connections)
        self.registry = FileRegistry()
        self.download_dir = download_dir or Path(".")
        self._a2a_agents: dict[str, A2AAgentInfo] = {}

    async def connect(self) -> None:
        """Connect to all agents via MCP SSE and discover A2A."""
        await self.hub.connect_all()
        # Discover A2A agents (tolerates failures)
        self._a2a_agents = await _a2a_discover(self._agent_urls)

    async def close(self) -> None:
        """Disconnect from all agents."""
        await self.hub.disconnect_all()
        await _a2a_close_all(self._a2a_agents)

    async def __aenter__(self) -> AgenturaClient:
        await self.connect()
        return self

    async def __aexit__(self, *exc) -> None:
        await self.close()

    def upload(self, path: Path, name: str | None = None) -> str:
        """Register a local file in the file registry.

        Returns the symbolic filename the LLM should use.
        """
        filename = name or path.name
        blob = path.read_bytes()
        mime, _ = mimetypes.guess_type(str(path))
        self.registry.register(
            filename,
            blob,
            mime=mime or "application/octet-stream",
            source="upload",
        )
        logger.info("Uploaded %s (%d bytes)", filename, len(blob))
        return filename

    def upload_bytes(
        self,
        blob: bytes,
        filename: str,
        mime: str = "application/octet-stream",
    ) -> str:
        """Register raw bytes in the file registry."""
        self.registry.register(
            filename,
            blob,
            mime=mime,
            source="upload",
        )
        return filename

    @property
    def tools(self):
        """All tools from all connected agents."""
        return self.hub.all_tools()

    async def call_tool(
        self,
        name: str,
        args: dict,
    ) -> ClientToolResult:
        """Call a tool with file middleware.

        1. Pre-middleware: resolve symbolic filenames to content
        2. MCP call_tool
        3. Post-middleware: fetch produced files, register, strip URLs
        """
        # Find tool schema for file param detection
        tool_schema = self.hub.tool_schema(name)
        if tool_schema is None:
            return ClientToolResult(
                text=f"Unknown tool: {name}",
                is_error=True,
            )

        # Pre-middleware: resolve file references
        processed_args = pre_process_tool_call(
            name,
            args,
            tool_schema,
            self.registry,
        )

        # MCP call
        result = await self.hub.call_tool(name, processed_args)

        # Check for errors
        if result.isError:
            text = result.content[0].text if result.content and hasattr(result.content[0], "text") else "Tool error"
            return ClientToolResult(text=text, is_error=True)

        # Post-middleware: fetch files, strip URLs
        agent = self.hub.agent_for_tool(name)
        text, new_files = await post_process_tool_result(
            name,
            result,
            agent,
            self.registry,
        )

        return ClientToolResult(
            text=text,
            files=new_files,
        )

    # A2A agent delegation

    @property
    def a2a_agents(self) -> dict[str, A2AAgentInfo]:
        """Discovered A2A agents."""
        return self._a2a_agents

    async def ask_agent(
        self,
        name: str,
        message: str,
        files: list[str] | None = None,
        data: dict | None = None,
        task_id: str | None = None,
    ) -> ClientA2AResult:
        """Delegate a task to an agent via A2A.

        The LLM calls this with symbolic filenames. Middleware resolves
        them to inline base64 content. Produced files are fetched and
        registered back in the registry.

        Args:
            name: Agent name (must match key in agents dict).
            message: Natural language request.
            files: Symbolic filenames from the registry.
            data: Optional structured data dict.
            task_id: Continue an existing task (multi-turn).

        Returns:
            ClientA2AResult with LLM-safe text (no URLs),
            registered files, task_id, and status.
        """
        agent = self._a2a_agents.get(name)
        if not agent:
            return ClientA2AResult(
                text=f"Agent '{name}' not found via A2A.",
                agent_name=name,
                is_error=True,
            )

        # Resolve symbolic filenames to FileAttachment dicts
        file_dicts = []
        for fname in files or []:
            entry = self.registry.get(fname)
            if entry and entry.blob:
                import base64 as _b64

                b64 = _b64.b64encode(entry.blob).decode()
                file_dicts.append(
                    {
                        "name": fname,
                        "content": f"data:{entry.mime};base64,{b64}",
                    }
                )
            else:
                logger.warning(
                    "File '%s' not in registry, skipping",
                    fname,
                )

        # Send via A2A
        result = await _a2a_send_task(
            agent,
            message,
            files=file_dicts or None,
            data=data,
            task_id=task_id,
        )

        # Fetch produced files and register them
        registered_files = []
        for fi in result.files:
            if not fi.url:
                continue
            try:
                async with httpx.AsyncClient(timeout=30) as hc:
                    resp = await hc.get(fi.url)
                    resp.raise_for_status()
                fname = fi.filename or fi.url.rsplit("/", 1)[-1]
                self.registry.register(
                    fname,
                    resp.content,
                    mime=fi.mime_type,
                    source=f"a2a:{name}",
                )
                registered_files.append(
                    self.registry.get(fname),
                )
                logger.info(
                    "A2A file registered: %s (%d bytes)",
                    fname,
                    len(resp.content),
                )
            except Exception:
                logger.warning(
                    "Failed to fetch A2A file %s",
                    fi.url,
                )

        # Strip URLs from text (LLM should only see filenames)
        text = result.text
        for fi in result.files:
            if fi.url and fi.url in text:
                text = text.replace(fi.url, fi.filename or "")

        return ClientA2AResult(
            text=text,
            agent_name=name,
            files=[f for f in registered_files if f],
            task_id=result.task_id,
            context_id=result.context_id,
            status=result.status,
            is_error=result.status == "failed",
        )

a2a_agents property

Discovered A2A agents.

tools property

All tools from all connected agents.

__init__(agents, download_dir=None)

Parameters:

Name Type Description Default
agents dict[str, str]

Mapping of agent name to base URL. e.g. {"document": "http://localhost:8002"}

required
download_dir Path | None

Directory for downloaded files. Defaults to a temp directory.

None
Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
def __init__(
    self,
    agents: dict[str, str],
    download_dir: Path | None = None,
) -> None:
    """
    Args:
        agents: Mapping of agent name to base URL.
            e.g. {"document": "http://localhost:8002"}
        download_dir: Directory for downloaded files.
            Defaults to a temp directory.
    """
    self._agent_urls = agents
    connections = [
        AgentConnection(
            name=name,
            url=f"{url}/mcp/sse",
            base_url=url.rstrip("/"),
        )
        for name, url in agents.items()
    ]
    self.hub = MCPHub(connections)
    self.registry = FileRegistry()
    self.download_dir = download_dir or Path(".")
    self._a2a_agents: dict[str, A2AAgentInfo] = {}

ask_agent(name, message, files=None, data=None, task_id=None) async

Delegate a task to an agent via A2A.

The LLM calls this with symbolic filenames. Middleware resolves them to inline base64 content. Produced files are fetched and registered back in the registry.

Parameters:

Name Type Description Default
name str

Agent name (must match key in agents dict).

required
message str

Natural language request.

required
files list[str] | None

Symbolic filenames from the registry.

None
data dict | None

Optional structured data dict.

None
task_id str | None

Continue an existing task (multi-turn).

None

Returns:

Type Description
ClientA2AResult

ClientA2AResult with LLM-safe text (no URLs),

ClientA2AResult

registered files, task_id, and status.

Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
async def ask_agent(
    self,
    name: str,
    message: str,
    files: list[str] | None = None,
    data: dict | None = None,
    task_id: str | None = None,
) -> ClientA2AResult:
    """Delegate a task to an agent via A2A.

    The LLM calls this with symbolic filenames. Middleware resolves
    them to inline base64 content. Produced files are fetched and
    registered back in the registry.

    Args:
        name: Agent name (must match key in agents dict).
        message: Natural language request.
        files: Symbolic filenames from the registry.
        data: Optional structured data dict.
        task_id: Continue an existing task (multi-turn).

    Returns:
        ClientA2AResult with LLM-safe text (no URLs),
        registered files, task_id, and status.
    """
    agent = self._a2a_agents.get(name)
    if not agent:
        return ClientA2AResult(
            text=f"Agent '{name}' not found via A2A.",
            agent_name=name,
            is_error=True,
        )

    # Resolve symbolic filenames to FileAttachment dicts
    file_dicts = []
    for fname in files or []:
        entry = self.registry.get(fname)
        if entry and entry.blob:
            import base64 as _b64

            b64 = _b64.b64encode(entry.blob).decode()
            file_dicts.append(
                {
                    "name": fname,
                    "content": f"data:{entry.mime};base64,{b64}",
                }
            )
        else:
            logger.warning(
                "File '%s' not in registry, skipping",
                fname,
            )

    # Send via A2A
    result = await _a2a_send_task(
        agent,
        message,
        files=file_dicts or None,
        data=data,
        task_id=task_id,
    )

    # Fetch produced files and register them
    registered_files = []
    for fi in result.files:
        if not fi.url:
            continue
        try:
            async with httpx.AsyncClient(timeout=30) as hc:
                resp = await hc.get(fi.url)
                resp.raise_for_status()
            fname = fi.filename or fi.url.rsplit("/", 1)[-1]
            self.registry.register(
                fname,
                resp.content,
                mime=fi.mime_type,
                source=f"a2a:{name}",
            )
            registered_files.append(
                self.registry.get(fname),
            )
            logger.info(
                "A2A file registered: %s (%d bytes)",
                fname,
                len(resp.content),
            )
        except Exception:
            logger.warning(
                "Failed to fetch A2A file %s",
                fi.url,
            )

    # Strip URLs from text (LLM should only see filenames)
    text = result.text
    for fi in result.files:
        if fi.url and fi.url in text:
            text = text.replace(fi.url, fi.filename or "")

    return ClientA2AResult(
        text=text,
        agent_name=name,
        files=[f for f in registered_files if f],
        task_id=result.task_id,
        context_id=result.context_id,
        status=result.status,
        is_error=result.status == "failed",
    )

call_tool(name, args) async

Call a tool with file middleware.

  1. Pre-middleware: resolve symbolic filenames to content
  2. MCP call_tool
  3. Post-middleware: fetch produced files, register, strip URLs
Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
async def call_tool(
    self,
    name: str,
    args: dict,
) -> ClientToolResult:
    """Call a tool with file middleware.

    1. Pre-middleware: resolve symbolic filenames to content
    2. MCP call_tool
    3. Post-middleware: fetch produced files, register, strip URLs
    """
    # Find tool schema for file param detection
    tool_schema = self.hub.tool_schema(name)
    if tool_schema is None:
        return ClientToolResult(
            text=f"Unknown tool: {name}",
            is_error=True,
        )

    # Pre-middleware: resolve file references
    processed_args = pre_process_tool_call(
        name,
        args,
        tool_schema,
        self.registry,
    )

    # MCP call
    result = await self.hub.call_tool(name, processed_args)

    # Check for errors
    if result.isError:
        text = result.content[0].text if result.content and hasattr(result.content[0], "text") else "Tool error"
        return ClientToolResult(text=text, is_error=True)

    # Post-middleware: fetch files, strip URLs
    agent = self.hub.agent_for_tool(name)
    text, new_files = await post_process_tool_result(
        name,
        result,
        agent,
        self.registry,
    )

    return ClientToolResult(
        text=text,
        files=new_files,
    )

close() async

Disconnect from all agents.

Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
async def close(self) -> None:
    """Disconnect from all agents."""
    await self.hub.disconnect_all()
    await _a2a_close_all(self._a2a_agents)

connect() async

Connect to all agents via MCP SSE and discover A2A.

Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
async def connect(self) -> None:
    """Connect to all agents via MCP SSE and discover A2A."""
    await self.hub.connect_all()
    # Discover A2A agents (tolerates failures)
    self._a2a_agents = await _a2a_discover(self._agent_urls)

upload(path, name=None)

Register a local file in the file registry.

Returns the symbolic filename the LLM should use.

Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
def upload(self, path: Path, name: str | None = None) -> str:
    """Register a local file in the file registry.

    Returns the symbolic filename the LLM should use.
    """
    filename = name or path.name
    blob = path.read_bytes()
    mime, _ = mimetypes.guess_type(str(path))
    self.registry.register(
        filename,
        blob,
        mime=mime or "application/octet-stream",
        source="upload",
    )
    logger.info("Uploaded %s (%d bytes)", filename, len(blob))
    return filename

upload_bytes(blob, filename, mime='application/octet-stream')

Register raw bytes in the file registry.

Source code in packages/semos-agentura-core/src/semos/agentura/core/client.py
def upload_bytes(
    self,
    blob: bytes,
    filename: str,
    mime: str = "application/octet-stream",
) -> str:
    """Register raw bytes in the file registry."""
    self.registry.register(
        filename,
        blob,
        mime=mime,
        source="upload",
    )
    return filename

MCPHub

Connects to external MCP servers and exposes their tools to an agent.

Manages persistent SSE connections to multiple MCP agents.

Supports a two-phase lifecycle: 1. discover() - connect, list tools, disconnect 2. ensure_connected() - reconnect lazily on first tool call

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
class MCPHub:
    """Manages persistent SSE connections to multiple MCP agents.

    Supports a two-phase lifecycle:
    1. discover() - connect, list tools, disconnect
    2. ensure_connected() - reconnect lazily on first tool call
    """

    def __init__(self, agents: list[AgentConnection]) -> None:
        self._agents = {a.name: a for a in agents}
        self._tool_to_agent: dict[str, str] = {}
        self._exit_stack: AsyncExitStack | None = None
        self._connect_lock: asyncio.Lock | None = None

    @property
    def agents(self) -> dict[str, AgentConnection]:
        return self._agents

    async def connect_all(self) -> None:
        """Open SSE connections, initialize sessions, list tools."""
        self._exit_stack = AsyncExitStack()
        for agent in self._agents.values():
            logger.info(
                "Connecting to %s at %s",
                agent.name,
                agent.url,
            )
            streams = await self._exit_stack.enter_async_context(
                sse_client(agent.url),
            )
            session = await self._exit_stack.enter_async_context(
                ClientSession(*streams),
            )
            await session.initialize()
            result = await session.list_tools()
            agent.session = session
            agent.tools = result.tools
            for tool in result.tools:
                self._tool_to_agent[tool.name] = agent.name
            logger.info(
                "Connected to %s: %d tools",
                agent.name,
                len(result.tools),
            )

    async def disconnect_all(self) -> None:
        """Close all connections (keeps tool metadata)."""
        for agent in self._agents.values():
            agent.session = None
        if self._exit_stack:
            await self._exit_stack.aclose()
            self._exit_stack = None

    async def ensure_connected(self) -> None:
        """Reconnect if not currently connected.

        Uses a lock to prevent parallel tool calls from
        triggering concurrent reconnections.
        """
        if self._connect_lock is None:
            self._connect_lock = asyncio.Lock()
        async with self._connect_lock:
            any_disconnected = any(a.session is None for a in self._agents.values())
            if not any_disconnected:
                return
            logger.info("Reconnecting to MCP agents...")
            # Suppress RuntimeError from SSE cancel scope
            # cleanup in a different task (anyio/mcp-sdk issue).
            try:
                if self._exit_stack:
                    await self._exit_stack.aclose()
                    self._exit_stack = None
            except (RuntimeError, BaseExceptionGroup):
                self._exit_stack = None
            await self.connect_all()

    async def discover(self) -> list[Tool]:
        """Connect, list tools, disconnect. Returns tool metadata.

        Tool metadata (names, schemas) is preserved after disconnect.
        """
        await self.connect_all()
        tools = self.all_tools()
        await self.disconnect_all()
        return tools

    def all_tools(self) -> list[Tool]:
        """Return all tools from all connected agents."""
        tools: list[Tool] = []
        for agent in self._agents.values():
            tools.extend(agent.tools)
        return tools

    def tool_schema(self, tool_name: str) -> Tool | None:
        """Get the Tool schema for a tool by name."""
        for agent in self._agents.values():
            for tool in agent.tools:
                if tool.name == tool_name:
                    return tool
        return None

    def agent_for_tool(self, tool_name: str) -> AgentConnection:
        """Look up which agent owns a tool."""
        agent_name = self._tool_to_agent[tool_name]
        return self._agents[agent_name]

    async def call_tool(
        self,
        tool_name: str,
        arguments: dict,
    ) -> CallToolResult:
        """Route a tool call to the correct agent."""
        await self.ensure_connected()
        agent = self.agent_for_tool(tool_name)
        assert agent.session is not None
        return await agent.session.call_tool(
            tool_name,
            arguments=arguments,
        )

agent_for_tool(tool_name)

Look up which agent owns a tool.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
def agent_for_tool(self, tool_name: str) -> AgentConnection:
    """Look up which agent owns a tool."""
    agent_name = self._tool_to_agent[tool_name]
    return self._agents[agent_name]

all_tools()

Return all tools from all connected agents.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
def all_tools(self) -> list[Tool]:
    """Return all tools from all connected agents."""
    tools: list[Tool] = []
    for agent in self._agents.values():
        tools.extend(agent.tools)
    return tools

call_tool(tool_name, arguments) async

Route a tool call to the correct agent.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
async def call_tool(
    self,
    tool_name: str,
    arguments: dict,
) -> CallToolResult:
    """Route a tool call to the correct agent."""
    await self.ensure_connected()
    agent = self.agent_for_tool(tool_name)
    assert agent.session is not None
    return await agent.session.call_tool(
        tool_name,
        arguments=arguments,
    )

connect_all() async

Open SSE connections, initialize sessions, list tools.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
async def connect_all(self) -> None:
    """Open SSE connections, initialize sessions, list tools."""
    self._exit_stack = AsyncExitStack()
    for agent in self._agents.values():
        logger.info(
            "Connecting to %s at %s",
            agent.name,
            agent.url,
        )
        streams = await self._exit_stack.enter_async_context(
            sse_client(agent.url),
        )
        session = await self._exit_stack.enter_async_context(
            ClientSession(*streams),
        )
        await session.initialize()
        result = await session.list_tools()
        agent.session = session
        agent.tools = result.tools
        for tool in result.tools:
            self._tool_to_agent[tool.name] = agent.name
        logger.info(
            "Connected to %s: %d tools",
            agent.name,
            len(result.tools),
        )

disconnect_all() async

Close all connections (keeps tool metadata).

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
async def disconnect_all(self) -> None:
    """Close all connections (keeps tool metadata)."""
    for agent in self._agents.values():
        agent.session = None
    if self._exit_stack:
        await self._exit_stack.aclose()
        self._exit_stack = None

discover() async

Connect, list tools, disconnect. Returns tool metadata.

Tool metadata (names, schemas) is preserved after disconnect.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
async def discover(self) -> list[Tool]:
    """Connect, list tools, disconnect. Returns tool metadata.

    Tool metadata (names, schemas) is preserved after disconnect.
    """
    await self.connect_all()
    tools = self.all_tools()
    await self.disconnect_all()
    return tools

ensure_connected() async

Reconnect if not currently connected.

Uses a lock to prevent parallel tool calls from triggering concurrent reconnections.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
async def ensure_connected(self) -> None:
    """Reconnect if not currently connected.

    Uses a lock to prevent parallel tool calls from
    triggering concurrent reconnections.
    """
    if self._connect_lock is None:
        self._connect_lock = asyncio.Lock()
    async with self._connect_lock:
        any_disconnected = any(a.session is None for a in self._agents.values())
        if not any_disconnected:
            return
        logger.info("Reconnecting to MCP agents...")
        # Suppress RuntimeError from SSE cancel scope
        # cleanup in a different task (anyio/mcp-sdk issue).
        try:
            if self._exit_stack:
                await self._exit_stack.aclose()
                self._exit_stack = None
        except (RuntimeError, BaseExceptionGroup):
            self._exit_stack = None
        await self.connect_all()

tool_schema(tool_name)

Get the Tool schema for a tool by name.

Source code in packages/semos-agentura-core/src/semos/agentura/core/mcp_client.py
def tool_schema(self, tool_name: str) -> Tool | None:
    """Get the Tool schema for a tool by name."""
    for agent in self._agents.values():
        for tool in agent.tools:
            if tool.name == tool_name:
                return tool
    return None

create_app

Wires a BaseAgentService into a FastAPI app serving MCP and A2A endpoints.

Build a FastAPI app with MCP SSE + A2A + /health + /files.

Routes

GET /health -> health check GET /mcp/sse -> MCP SSE stream POST /mcp/messages/ -> MCP messages GET /.well-known/agent-card.json -> A2A Agent Card POST /a2a -> A2A JSON-RPC (when implemented) GET /files/ -> download output files

Source code in packages/semos-agentura-core/src/semos/agentura/core/transport.py
def create_app(
    service: BaseAgentService,
    *,
    base_url: str | None = None,
) -> FastAPI:
    """Build a FastAPI app with MCP SSE + A2A + /health + /files.

    Routes:
        GET  /health                      -> health check
        GET  /mcp/sse                     -> MCP SSE stream
        POST /mcp/messages/               -> MCP messages
        GET  /.well-known/agent-card.json -> A2A Agent Card
        POST /a2a                         -> A2A JSON-RPC (when implemented)
        GET  /files/<filename>            -> download output files
    """
    # Pre-configure logging with a plain handler so FastMCP's
    # configure_logging (which installs RichHandler) becomes a no-op.
    if not logging.root.handlers:
        level = os.environ.get("LOG_LEVEL", "INFO").upper()
        logging.basicConfig(
            level=getattr(logging, level, logging.INFO),
            format="%(asctime)s  %(name)-30s  %(levelname)-8s  %(message)s",
        )

    url = base_url or "http://127.0.0.1:8000"

    # Output directory for files produced by tools
    output_dir = _output_dir_for(service)
    _cleanup_old_files(output_dir)
    service.output_dir = output_dir
    service.base_url = url

    app = FastAPI(
        title=service.agent_name,
        description=service.agent_description,
        version=service.agent_version,
    )

    @app.get("/health")
    async def health():
        return {"status": "ok", "agent": service.agent_name}

    # Static file serving for tool outputs
    app.mount("/files", StaticFiles(directory=str(output_dir)), name="files")

    # MCP SSE (mount_path="" - sub-app is already at /mcp)
    mcp_server = create_mcp_server(service)
    mcp_sse = mcp_server.sse_app(mount_path="")
    app.mount("/mcp", mcp_sse)

    # A2A: REST + JSON-RPC (dual binding)
    try:
        from a2a.server.apps.jsonrpc.fastapi_app import (
            A2AFastAPIApplication,
        )
        from a2a.server.apps.rest.fastapi_app import (
            A2ARESTFastAPIApplication,
        )
        from fastapi.responses import JSONResponse
        from google.protobuf.json_format import MessageToDict

        agent_card = create_agent_card(service, base_url=url)
        a2a_handler = create_a2a_handler(service)

        # REST (HTTP+JSON) at /a2a/*
        a2a_rest = A2ARESTFastAPIApplication(
            agent_card=agent_card,
            http_handler=a2a_handler,
        )
        a2a_rest_app = a2a_rest.build(rpc_url="/a2a")
        for route in a2a_rest_app.routes:
            app.routes.append(route)

        # JSON-RPC at /a2a/rpc
        a2a_rpc = A2AFastAPIApplication(
            agent_card=agent_card,
            http_handler=a2a_handler,
        )
        a2a_rpc.add_routes_to_app(app, rpc_url="/a2a/rpc")

        # Agent card (shared, served at well-known path)
        @app.get("/.well-known/agent-card.json")
        async def get_agent_card():
            return JSONResponse(
                content=MessageToDict(agent_card),
            )

        logger.info(
            "A2A routes: REST at /a2a, JSON-RPC at /a2a/rpc",
        )
    except ImportError:
        logger.warning(
            "a2a-sdk not available, A2A routes not mounted",
        )

    return app

Agents

DocumentAgentService

Document digestion (OCR to Markdown) and composition (Markdown to documents).

Bases: BaseAgentService

Exposes document-agent's tools via MCP and skills via A2A.

Source code in packages/semos-agentura-document/src/semos/agentura/document/service.py
class DocumentAgentService(BaseAgentService):
    """Exposes document-agent's tools via MCP and skills via A2A."""

    def __init__(self) -> None:
        self._settings = Settings()

    @property
    def agent_name(self) -> str:
        return "Document Agent"

    @property
    def agent_description(self) -> str:
        return "Document processing - digest (OCR), compose (render), generate diagrams and images, and fill forms."

    @property
    def agent_version(self) -> str:
        return "0.1.0"

    def get_tools(self) -> list:
        from .tools import get_document_tools

        return get_document_tools(self)

    def get_skills(self) -> list[SkillDef]:
        return [
            SkillDef(
                id="document-processing",
                name="Document Processing",
                description="Digest, compose, diagram generation, and form operations on documents.",
                tags=["document", "ocr", "pdf", "diagram"],
            ),
        ]

    async def execute_skill(self, skill_id: str, message: str, *, task_id: str | None = None) -> str:
        msg = message.lower()
        if "digest" in msg or "ocr" in msg:
            return "Use the digest_document tool with a file path to extract content from a document."
        elif "compose" in msg or "render" in msg:
            return "Use the compose_document tool with Markdown content and an output format."
        elif "diagram" in msg:
            return "Use the generate_diagram tool with a text description."
        elif "image" in msg or "icon" in msg or "raster" in msg:
            return "Use the generate_image tool with a description and mode (generate/edit/cut)."
        elif "form" in msg and "fill" in msg:
            return "Use the fill_form tool with a file path and field data."
        elif "form" in msg and "inspect" in msg:
            return "Use the inspect_form tool with a file path."
        return (
            "Available tools: digest_document (supports DOCX tracked changes, footnotes, comments), "
            "compose_document (supports footnotes and reference doc for styles), "
            "generate_diagram, generate_image, inspect_form, fill_form."
        )

    async def _get_examples(self) -> str:
        """Return reference Markdown examples for all composition tools."""
        return json.dumps(
            {
                "document": _EXAMPLE_DOCUMENT,
                "marp_slides": _EXAMPLE_MARP_SLIDES,
                "pandoc_slides": _EXAMPLE_PANDOC_SLIDES,
                "description": (
                    "Three Markdown formats supported by compose_document:\n"
                    "1. 'document': General documents (PDF, DOCX) with inline "
                    "mermaid/drawio diagrams, YAML styles, footnotes.\n"
                    "2. 'marp_slides': Marp presentations (is_slides=true). "
                    "Polished output. Use draft=true for editable PPTX.\n"
                    "3. 'pandoc_slides': Native pandoc slide markdown "
                    "(is_slides=true, draft=true). Always fully editable."
                ),
            },
            ensure_ascii=False,
        )

EmailAgentService

Unified email client over IMAP/SMTP and Outlook COM, with the @mailgent LLM agent.

Bases: BaseAgentService

Exposes email-agent's tools via MCP and skills via A2A.

Source code in packages/semos-agentura-email/src/semos/agentura/email/service.py
class EmailAgentService(BaseAgentService):
    """Exposes email-agent's tools via MCP and skills via A2A."""

    def __init__(self) -> None:
        self._settings = Settings()
        self._com_worker: _COMWorker | None = None
        self._backend_instance: EmailBackend | None = None

    async def run_sync(self, fn: Callable[[EmailBackend], T]) -> T:
        """Run fn(backend) on the appropriate thread.

        COM: dedicated worker thread (apartment-threaded).
        IMAP/Graph: asyncio.to_thread (thread-safe).
        Pre-injected mock: used directly (tests).
        """
        # If backend already injected (mock or previous init), use it
        if self._backend_instance is not None:
            return await asyncio.to_thread(fn, self._backend_instance)
        if self._settings.detected_backend == "com":
            if self._com_worker is None:
                self._com_worker = _COMWorker(self._settings)
            return await self._com_worker.run(fn)
        self._backend_instance = create_backend(self._settings)
        self._backend_instance.connect()
        logger.info(
            "Backend connected: %s",
            self._settings.detected_backend,
        )
        return await asyncio.to_thread(fn, self._backend_instance)

    @property
    def agent_name(self) -> str:
        return "Email Agent"

    @property
    def agent_description(self) -> str:
        return "Email and calendar operations - search, read, send, draft, reply, and manage events."

    @property
    def agent_version(self) -> str:
        return "0.2.0"

    @property
    def agent_system_prompt(self) -> str:
        return (
            super().agent_system_prompt + "\n\n" + "You are an email and calendar assistant. "
            "Use the specialized tools - do NOT try to "
            "compute results manually. "
            "For availability or free time queries, ALWAYS use "
            "the free_slots tool (not list_events). "
            "Use list_events only when the user needs event "
            "details (subjects, attendees, locations). "
            "NEVER guess the current year, month, day, time, "
            "or day-of-week mappings. "
            "Always use the current date/time provided above."
        )

    def get_tools(self) -> list:
        from .tools import get_email_tools

        return get_email_tools(self)

    def get_skills(self) -> list[SkillDef]:
        return [
            SkillDef(
                id="email-operations",
                name="Email Operations",
                description=("Search, read, send, and draft emails and calendar events."),
                tags=["email", "calendar", "outlook"],
            ),
        ]

    async def execute_skill(
        self,
        skill_id: str,
        message: str,
        *,
        task_id: str | None = None,
    ) -> str:
        msg = message.lower()
        tools = self.get_tools()
        if "search" in msg:
            t = next(t for t in tools if t.name == "search_emails")
            return await t._arun(query=message)
        elif "free" in msg or "slot" in msg:
            t = next(t for t in tools if t.name == "free_slots")
            return await t._arun()
        elif "event" in msg or "calendar" in msg:
            t = next(t for t in tools if t.name == "list_events")
            return await t._arun()
        else:
            t = next(t for t in tools if t.name == "search_emails")
            return await t._arun(query=message)

run_sync(fn) async

Run fn(backend) on the appropriate thread.

COM: dedicated worker thread (apartment-threaded). IMAP/Graph: asyncio.to_thread (thread-safe). Pre-injected mock: used directly (tests).

Source code in packages/semos-agentura-email/src/semos/agentura/email/service.py
async def run_sync(self, fn: Callable[[EmailBackend], T]) -> T:
    """Run fn(backend) on the appropriate thread.

    COM: dedicated worker thread (apartment-threaded).
    IMAP/Graph: asyncio.to_thread (thread-safe).
    Pre-injected mock: used directly (tests).
    """
    # If backend already injected (mock or previous init), use it
    if self._backend_instance is not None:
        return await asyncio.to_thread(fn, self._backend_instance)
    if self._settings.detected_backend == "com":
        if self._com_worker is None:
            self._com_worker = _COMWorker(self._settings)
        return await self._com_worker.run(fn)
    self._backend_instance = create_backend(self._settings)
    self._backend_instance.connect()
    logger.info(
        "Backend connected: %s",
        self._settings.detected_backend,
    )
    return await asyncio.to_thread(fn, self._backend_instance)

FilesystemAgentService

Multi-root virtual filesystem (local, WebDAV, SharePoint, archives) exposed as MCP/A2A tools.

Bases: BaseAgentService

Source code in packages/semos-agentura-files/src/semos/agentura/files/service.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
class FilesystemAgentService(BaseAgentService):
    def __init__(self, vfs: VirtualFileSystem | None = None) -> None:
        self._settings = Settings()
        self._vfs = vfs

    def _ensure_vfs(self) -> VirtualFileSystem:
        if self._vfs is None:
            self._vfs = _build_vfs(self._settings)
        return self._vfs

    @property
    def agent_name(self) -> str:
        return "Filesystem Agent"

    @property
    def agent_description(self) -> str:
        return "Browse, search, read, write files across local, WebDAV, SharePoint, and remote archives."

    @property
    def agent_version(self) -> str:
        return "0.2.0"

    def get_tools(self) -> list:
        from .tools import get_filesystem_tools

        return get_filesystem_tools(self)

    def get_skills(self) -> list[SkillDef]:
        return [
            SkillDef(
                id="filesystem-operations",
                name="Filesystem Operations",
                description="Browse, search, read, write, copy, move files across local and remote filesystems.",
                tags=["filesystem", "sharepoint", "webdav", "archive"],
                examples=[
                    "List all PDF files in the SharePoint shared folder",
                    "Copy budget.csv from local to webdav shared",
                    "Search SharePoint for documents about mTLS",
                    "Show what's inside the sample.zip archive",
                ],
            ),
        ]

    async def execute_skill(self, skill_id: str, message: str, *, task_id: str | None = None) -> str:
        # If an LLM router is configured, use tool-calling dispatch
        if self.router_llm_model:
            return await self._llm_dispatch(message)

        # Keyword-based fallback
        msg = message.lower()
        if "search" in msg and "sharepoint" in msg:
            return await self._search_sharepoint(query=message)
        elif "list" in msg or "show" in msg or "browse" in msg:
            # Try to extract a URI from the message
            uri = self._extract_uri(message) or ""
            return await self._list_files(uri=uri)
        elif ("read" in msg or "open" in msg) and "://" in message:
            uri = self._extract_uri(message) or ""
            return await self._read_file(uri=uri)
        elif "write" in msg or "save" in msg or "create" in msg:
            return "Use the write_file tool with a URI like session://filename.md and the content."
        elif "tree" in msg:
            uri = self._extract_uri(message) or ""
            return await self._file_tree(uri=uri)
        else:
            return await self._list_files(uri="")

    async def _llm_dispatch(self, message: str) -> str:
        """Use litellm with tool-calling to dispatch natural language requests."""
        import litellm

        tools_schema = []
        tool_map: dict[str, Any] = {}
        for td in self.get_tools():
            schema = td.get_input_schema()
            tools_schema.append(
                {
                    "type": "function",
                    "function": {
                        "name": td.name,
                        "description": td.description,
                        "parameters": schema,
                    },
                }
            )
            tool_map[td.name] = td

        response = await litellm.acompletion(
            model=self.router_llm_model,
            api_key=self.router_llm_api_key or None,
            api_base=self.router_llm_api_base or None,
            messages=[
                {
                    "role": "system",
                    "content": "You are a filesystem agent. Use the available tools to fulfill the user's request.",
                },
                {"role": "user", "content": message},
            ],
            tools=tools_schema,
            tool_choice="auto",
        )

        msg = response.choices[0].message
        if msg.tool_calls:
            results = []
            for tc in msg.tool_calls:
                tool = tool_map.get(tc.function.name)
                if tool:
                    args = json.loads(tc.function.arguments)
                    result = await tool._arun(**args)
                    results.append(f"[{tc.function.name}]: {result}")
            return "\n\n".join(results)

        return msg.content or "No result"

    @staticmethod
    def _extract_uri(message: str) -> str | None:
        """Try to find a VFS URI in a natural language message."""
        for word in message.split():
            if "://" in word:
                return word.strip("\"'`.,;:")
        return None

    # Tool implementations

    async def _list_files(self, uri: str = "") -> str:
        vfs = self._ensure_vfs()
        entries = vfs.ls(uri, detail=True)
        return json.dumps(entries, ensure_ascii=False, indent=2, default=str)

    async def _file_info(self, uri: str = "") -> str:
        vfs = self._ensure_vfs()
        info = vfs.info(uri)
        return json.dumps(info, ensure_ascii=False, indent=2, default=str)

    async def _read_file(self, uri: str = "") -> str:
        if not uri or "://" not in uri:
            return "Error: provide a valid URI (e.g. session://file.txt)"
        vfs = self._ensure_vfs()
        data = vfs.cat(uri)
        try:
            text = data.decode("utf-8")
            # Guard against accidentally reading huge text files
            if len(text) > 500_000:
                return (
                    f"[text file, {len(text)} chars - too large for context. "
                    f"Use grep/glob to search, or pass the filename directly to tools.]"
                )
            return text
        except UnicodeDecodeError:
            import mimetypes

            mime, _ = mimetypes.guess_type(uri)
            return (
                f"[binary file: {uri.rsplit('/', 1)[-1]}, "
                f"{len(data)} bytes, {mime or 'unknown type'}. "
                f"Do NOT read binary files into context. "
                f"Just pass the filename to tools - the file middleware "
                f"resolves content automatically.]"
            )

    async def _file_tree(self, uri: str = "", depth: int = 2) -> str:
        vfs = self._ensure_vfs()
        tree = vfs.tree(uri, depth=depth)
        return json.dumps(tree, ensure_ascii=False, indent=2, default=str)

    async def _write_file(
        self,
        uri: str,
        content: str,
        mode: WriteMode | str = WriteMode.OVERWRITE,
    ) -> str:
        # uri and content are required by WriteFileInput; the MCP wrapper
        # validates against that schema before dispatch (see mcp_server.py),
        # which stops silent 0-char writes when content is omitted.
        mode = WriteMode(mode)  # accept enum or wire string
        vfs = self._ensure_vfs()
        data = content.encode("utf-8")
        if mode is WriteMode.APPEND:
            try:
                existing = vfs.cat(uri)
            except FileNotFoundError:
                existing = b""
            data = existing + data
            vfs.put(uri, data)
            return f"Appended {len(content)} chars to {uri} (now {len(data)} bytes)"
        vfs.put(uri, data)
        return f"Written {len(content)} chars to {uri}"

    async def _create_folder(self, uri: str = "") -> str:
        vfs = self._ensure_vfs()
        vfs.mkdir(uri)
        return f"Created folder {uri}"

    async def _move_file(self, source: str = "", destination: str = "") -> str:
        vfs = self._ensure_vfs()
        vfs.mv(source, destination)
        return f"Moved {source} to {destination}"

    async def _copy_file(self, source: str = "", destination: str = "") -> str:
        vfs = self._ensure_vfs()
        vfs.cp(source, destination)
        return f"Copied {source} to {destination}"

    async def _delete_file(self, uri: str = "", recursive: bool = False) -> str:
        vfs = self._ensure_vfs()
        vfs.rm(uri, recursive=recursive)
        return f"Deleted {uri}"

    async def _list_archive(self, uri: str = "") -> str:
        vfs = self._ensure_vfs()
        entries = vfs.ls_archive(uri)
        return json.dumps(entries, ensure_ascii=False, indent=2)

    async def _read_archive_file(self, uri: str = "") -> str:
        vfs = self._ensure_vfs()
        data = vfs.cat_archive(uri)
        try:
            return data.decode("utf-8")
        except UnicodeDecodeError:
            import base64

            return f"[binary, {len(data)} bytes, base64]: {base64.b64encode(data).decode()}"

    async def _grep(self, pattern: str, uri: str = "", depth: int = 3, max_results: int = 100) -> str:
        """Search file contents for a regex pattern across VFS."""
        vfs = self._ensure_vfs()
        try:
            compiled = re.compile(pattern)
        except re.error as e:
            return json.dumps({"error": f"Invalid regex: {e}"})

        tree = vfs.tree(uri, depth=depth)
        file_uris = self._collect_file_uris(tree)

        matches: list[dict] = []
        sem = asyncio.Semaphore(10)

        async def _scan_file(file_uri: str) -> list[dict]:
            async with sem:
                try:
                    data = await asyncio.to_thread(vfs.cat, file_uri)
                    if len(data) > 1_000_000:
                        return []
                    text = data.decode("utf-8")
                except (UnicodeDecodeError, Exception):
                    return []
                hits = []
                for i, line in enumerate(text.splitlines(), 1):
                    if compiled.search(line):
                        hits.append({"uri": file_uri, "line_number": i, "line": line.strip()})
                        if len(hits) >= 10:
                            break
                return hits

        tasks = [_scan_file(u) for u in file_uris]
        for result in await asyncio.gather(*tasks, return_exceptions=True):
            if isinstance(result, list):
                matches.extend(result)
                if len(matches) >= max_results:
                    matches = matches[:max_results]
                    break

        return json.dumps(matches, ensure_ascii=False, indent=2)

    async def _glob(self, pattern: str, uri: str = "", depth: int = 5, max_results: int = 500) -> str:
        """Find files by name pattern across VFS."""
        vfs = self._ensure_vfs()
        tree = vfs.tree(uri, depth=depth)
        all_entries = self._collect_entries(tree)
        matched = [e for e in all_entries if fnmatch.fnmatch(e.get("name", ""), pattern)][:max_results]
        return json.dumps(matched, ensure_ascii=False, indent=2)

    async def _edit_file(self, uri: str, old_text: str, new_text: str, replace_all: bool = False) -> str:
        """Edit a text file by replacing a specific string."""
        vfs = self._ensure_vfs()
        try:
            data = vfs.cat(uri).decode("utf-8")
        except UnicodeDecodeError:
            return json.dumps({"error": "File is binary, cannot edit as text"})
        count = data.count(old_text)
        if count == 0:
            return json.dumps({"error": "old_text not found in file"})
        if count > 1 and not replace_all:
            return json.dumps(
                {"error": f"Ambiguous: old_text occurs {count} times. Use replace_all=true or provide more context."}
            )
        if replace_all:
            new_data = data.replace(old_text, new_text)
        else:
            new_data = data.replace(old_text, new_text, 1)
        vfs.put(uri, new_data.encode("utf-8"))
        return json.dumps({"edited": uri, "replacements": count if replace_all else 1})

    async def _batch_edit(self, uri: str, edits: list[dict] | None = None) -> str:
        """Apply multiple search/replace edits to a file."""
        if not edits:
            return json.dumps({"error": "edits list is required"})
        vfs = self._ensure_vfs()
        try:
            data = vfs.cat(uri).decode("utf-8")
        except UnicodeDecodeError:
            return json.dumps({"error": "File is binary, cannot edit as text"})
        applied = 0
        for i, edit in enumerate(edits):
            old = edit.get("old", "")
            new = edit.get("new", "")
            if not old:
                return json.dumps({"error": f"Edit {i}: 'old' field is empty"})
            if old not in data:
                return json.dumps({"error": f"Edit {i}: '{old[:60]}...' not found in file"})
            data = data.replace(old, new, 1)
            applied += 1
        vfs.put(uri, data.encode("utf-8"))
        return json.dumps({"edited": uri, "edits_applied": applied})

    @staticmethod
    def _collect_file_uris(tree: list[dict]) -> list[str]:
        """Recursively collect file URIs from a tree structure."""
        uris: list[str] = []
        for entry in tree:
            if entry.get("type") == "file":
                uris.append(entry["uri"])
            for child in entry.get("children") or []:
                if child.get("type") == "file":
                    uris.append(child["uri"])
                uris.extend(FilesystemAgentService._collect_file_uris(child.get("children") or []))
        return uris

    @staticmethod
    def _collect_entries(tree: list[dict]) -> list[dict]:
        """Recursively collect all entries from a tree structure."""
        entries: list[dict] = []
        for entry in tree:
            entries.append(
                {
                    "uri": entry.get("uri", ""),
                    "name": entry.get("name", ""),
                    "type": entry.get("type", ""),
                    "size": entry.get("size", ""),
                }
            )
            for child in entry.get("children") or []:
                entries.append(
                    {
                        "uri": child.get("uri", ""),
                        "name": child.get("name", ""),
                        "type": child.get("type", ""),
                        "size": child.get("size", ""),
                    }
                )
                entries.extend(FilesystemAgentService._collect_entries(child.get("children") or []))
        return entries

    async def _search_sharepoint(self, query: str = "", limit: int = 20) -> str:
        """Search SharePoint via REST API."""
        from urllib.parse import quote

        import httpx

        from .auth import CookieAuth, extract_sharepoint_cookies

        settings = self._settings
        if not settings.sharepoint_site_url:
            return json.dumps({"error": "SHAREPOINT_SITE_URL not configured"})

        cookies, _ = extract_sharepoint_cookies(settings.sharepoint_site_url)
        site = settings.sharepoint_site_url
        props = "Title,Path,Filename,Size,LastModifiedTime,Author,FileExtension"
        url = f"{site}/_api/search/query?querytext='{quote(query)}'&rowlimit={limit}&selectproperties='{props}'"

        with httpx.Client(auth=CookieAuth(cookies), timeout=30) as client:
            r = client.get(url, headers={"Accept": "application/json;odata=verbose"}, follow_redirects=True)
            r.raise_for_status()
            data = r.json()

        rows = (
            data.get("d", {})
            .get("query", {})
            .get("PrimaryQueryResult", {})
            .get("RelevantResults", {})
            .get("Table", {})
            .get("Rows", {})
            .get("results", [])
        )
        results = []
        for row in rows:
            cells = {c["Key"]: c["Value"] for c in row.get("Cells", {}).get("results", [])}
            results.append(
                {
                    "title": cells.get("Title", ""),
                    "path": cells.get("Path", ""),
                    "filename": cells.get("Filename", ""),
                    "size": cells.get("Size", ""),
                    "modified": cells.get("LastModifiedTime", ""),
                    "author": cells.get("Author", ""),
                    "extension": cells.get("FileExtension", ""),
                }
            )
        return json.dumps(results, ensure_ascii=False, indent=2)

    async def _add_root(
        self,
        name: str = "",
        protocol: str = "local",
        base_path: str = "",
        kwargs: dict | None = None,
    ) -> str:
        """Mount a new filesystem root."""
        from ._schemas import _PROTOCOL_SCHEMAS

        if not name:
            return json.dumps({"error": "name is required"})

        # Validate protocol against per-protocol const values
        valid_protocols = [
            s["properties"]["protocol"]["const"]
            for s in _PROTOCOL_SCHEMAS
            if "properties" in s and "protocol" in s["properties"]
        ]
        if valid_protocols and protocol not in valid_protocols:
            return json.dumps({"error": f"Unknown protocol '{protocol}'. Must be one of: {valid_protocols}"})

        vfs = self._ensure_vfs()
        opts = kwargs or {}

        if protocol == "sharepoint":
            return await self._add_sharepoint_root(name, base_path, opts)
        if protocol == "google_drive":
            return await self._add_google_drive_root(name, opts)

        vfs.add_root_from_protocol(name, protocol, base_path=base_path, **opts)
        return json.dumps({"mounted": name, "protocol": protocol, "base_path": base_path})

    async def _add_sharepoint_root(self, name: str, base_path: str, opts: dict) -> str:
        """Mount a SharePoint root via WebDAV with automatic cookie auth."""
        from urllib.parse import quote

        from ._sharepoint import (
            detect_doc_library,
            is_file_sharing_link,
            is_sharing_link,
            resolve_sharepoint_url,
            resolve_sharing_link_file,
            resolve_sharing_link_folder,
        )
        from .auth import CookieAuth, extract_sharepoint_cookies

        raw_url = opts.get("site_url", "")
        if not raw_url:
            return json.dumps({"error": "kwargs.site_url is required for sharepoint protocol"})

        sharing = is_sharing_link(raw_url)
        site_url, resolved_subfolder = resolve_sharepoint_url(raw_url)

        # For sharing links, authenticate via the original URL (triggers email
        # code flow for external users). For direct URLs, use the site URL.
        auth_url = raw_url if sharing else site_url
        cookies, redirect_url = extract_sharepoint_cookies(auth_url)
        if not cookies.get("FedAuth"):
            return json.dumps({"error": "SharePoint login failed - no FedAuth cookie obtained"})

        auth = CookieAuth(cookies)

        # Extract shared folder path from sharing link redirect.
        # Always resolve via HTTP (browser URL may not contain ?id= param).
        if sharing and not opts.get("subfolder", ""):
            resolved_subfolder = resolve_sharing_link_folder(raw_url, site_url, auth)

        # Auto-detect primary document library if not specified.
        # SharePoint's localized URL name (e.g. "Freigegebene Dokumente") differs
        # from the English "Shared Documents" - query the API to get the real path.
        doc_library = opts.get("doc_library", "")
        if not doc_library:
            doc_library = detect_doc_library(site_url, auth)

        subfolder = opts.get("subfolder", "") or resolved_subfolder

        webdav_url = f"{site_url}/{quote(doc_library)}"
        # DirFileSystem base_path is a filesystem path, not a URL - don't encode it.
        # "/" is the minimum non-empty value for WebDAV.
        folder_path = subfolder if subfolder else "/"

        from webdav4.fsspec import WebdavFileSystem

        wfs = WebdavFileSystem(webdav_url, auth=auth)

        # Validate connection before adding to VFS (prevents poisoning other roots)
        try:
            wfs.ls(folder_path if folder_path != "/" else "")
        except Exception as exc:
            # For file sharing links, fall back to single-file virtual mount.
            # File-only shares don't grant folder access via WebDAV.
            if sharing and is_file_sharing_link(raw_url):
                filename = resolve_sharing_link_file(raw_url, site_url, auth)
                if filename:
                    from .vfs import SingleFileWebdavFS

                    file_url = (
                        f"{webdav_url}/{quote(subfolder)}/{quote(filename)}"
                        if subfolder
                        else f"{webdav_url}/{quote(filename)}"
                    )
                    sfs = SingleFileWebdavFS(file_url, filename, auth)
                    # Validate the single file is accessible
                    try:
                        sfs.info(filename)
                    except Exception:
                        return json.dumps({"error": f"Cannot access shared file: {filename}"})
                    vfs = self._ensure_vfs()
                    vfs.add_root(name, sfs)
                    return json.dumps(
                        {
                            "mounted": name,
                            "protocol": "sharepoint",
                            "mode": "single-file",
                            "site_url": site_url,
                            "file": filename,
                        }
                    )
            return json.dumps({"error": f"Cannot access {webdav_url}: {exc}"})

        vfs = self._ensure_vfs()
        vfs.add_root(name, wfs, base_path=folder_path)
        return json.dumps(
            {
                "mounted": name,
                "protocol": "sharepoint",
                "site_url": site_url,
                "doc_library": doc_library,
                "subfolder": subfolder,
            }
        )

    async def _remove_root(self, name: str = "") -> str:
        """Unmount a filesystem root."""
        if not name:
            return json.dumps({"error": "name is required"})
        vfs = self._ensure_vfs()
        vfs.remove_root(name)
        return json.dumps({"unmounted": name})

    async def _list_roots(self) -> str:
        """List all mounted filesystem roots."""
        vfs = self._ensure_vfs()
        return json.dumps(vfs.roots_info(), ensure_ascii=False, indent=2)

    async def _add_google_drive_root(self, name: str, opts: dict) -> str:
        """Mount a Google Drive root via sharing link with browser-based auth."""
        from ._google_drive import EXPORT_MIMES, resolve_google_drive_url
        from .auth import extract_google_drive_auth
        from .vfs import SingleFileGDriveFS

        raw_url = opts.get("share_url", "")
        if not raw_url:
            return json.dumps({"error": "kwargs.share_url is required for google_drive protocol"})

        target = resolve_google_drive_url(raw_url)
        if not target.item_id:
            return json.dumps({"error": f"Could not extract Drive item ID from: {raw_url}"})

        auth = extract_google_drive_auth(raw_url)
        if not auth.token:
            return json.dumps({"error": "Google Drive login failed - no OAuth token obtained"})

        # Resolve item metadata
        import httpx

        # Virtual roots don't have file metadata
        if target.item_id == "root":
            item_name = "My Drive"
        elif target.item_id == "sharedWithMe":
            item_name = "Shared with me"
        else:
            meta_r = httpx.get(
                f"https://www.googleapis.com/drive/v3/files/{target.item_id}",
                auth=auth,
                params={"fields": "name,mimeType,size"},
                follow_redirects=True,
                timeout=15,
            )
            if meta_r.status_code != 200:
                return json.dumps({"error": f"Cannot access Drive item {target.item_id}: {meta_r.status_code}"})
            meta = meta_r.json()
            item_name = meta.get("name", target.item_id)

        if target.item_type in ("folder", "shared"):
            # Validate access
            if target.item_id == "sharedWithMe":
                q = "sharedWithMe=true and trashed=false"
            else:
                q = f"'{target.item_id}' in parents and trashed=false"
            list_r = httpx.get(
                "https://www.googleapis.com/drive/v3/files",
                auth=auth,
                params={
                    "q": q,
                    "fields": "files(id,name,mimeType,size,modifiedTime)",
                    "pageSize": 1,
                },
                follow_redirects=True,
                timeout=15,
            )
            if list_r.status_code != 200:
                return json.dumps({"error": f"Cannot list Drive folder: {list_r.status_code}"})

            from .vfs import GDriveFolderFS

            gfs = GDriveFolderFS(folder_id=target.item_id, auth=auth)
            vfs = self._ensure_vfs()
            vfs.add_root(name, gfs)
            return json.dumps(
                {
                    "mounted": name,
                    "protocol": "google_drive",
                    "folder_name": item_name,
                    "folder_id": target.item_id,
                }
            )

        # Single file or Google Doc
        filename = item_name
        if target.is_google_doc and target.item_type in EXPORT_MIMES:
            _, ext = EXPORT_MIMES[target.item_type]
            if not filename.endswith(ext):
                filename += ext

        sfs = SingleFileGDriveFS(
            file_id=target.item_id,
            filename=filename,
            auth=auth,
            item_type=target.item_type,
        )
        # Validate
        try:
            sfs.info(filename)
        except Exception as exc:
            return json.dumps({"error": f"Cannot access shared file: {exc}"})

        vfs = self._ensure_vfs()
        vfs.add_root(name, sfs)
        return json.dumps(
            {
                "mounted": name,
                "protocol": "google_drive",
                "mode": "export" if target.is_google_doc else "single-file",
                "file_id": target.item_id,
                "file": filename,
            }
        )