mirror of
https://github.com/SHOGGOTH-SECTOR/sica-fondt.git
synced 2026-08-01 00:23:15 +00:00
Place useful parts of the surrounding repos into sica-fondt by layer, per the
body model (Ada = membrane; brain/endocrine/capabilities/knowledge non-Ada):
- brain/ LLM reasoning + providers (dapr, hermes, MoMoA)
- capabilities/ REPRAG sidecars: hermes tools/skills, dapr tools, parallel
dispatch, A51 channels, and the OSINT cluster
- knowledge/ LORAG corpus: 754 cyber-skills, agency personas, secure-coding,
MITRE ATT&CK data
- reference/ defensive threat-reference (C3, shhbruh doc) + AdaYaml parser
License handling: AGPL sources (worldosint, advanced_evolution, mercury,
Reticulum) and GPL DeTTECT are SPEC-only clean-room/port descriptions — no
copyleft code copied. MIT/Apache/data parts copied as working trees.
Safety: shhbruh escape/persistence material and C3 covert-C2 kept as reference
only, not wired into the running organism. See CONSOLIDATION.md.
https://claude.ai/code/session_01UehUqEXXJJCsHoA4voCU5c
534 lines
21 KiB
Python
534 lines
21 KiB
Python
#
|
||
# Copyright 2026 The Dapr Authors
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
#
|
||
|
||
import logging
|
||
import os
|
||
import time
|
||
from pathlib import Path
|
||
from typing import (
|
||
Any,
|
||
ClassVar,
|
||
Dict,
|
||
Iterable,
|
||
List,
|
||
Literal,
|
||
Optional,
|
||
Type,
|
||
Union,
|
||
)
|
||
|
||
from pydantic import BaseModel, Field
|
||
from dapr_agents.llm.chat import ChatClientBase
|
||
from dapr_agents.llm.dapr.client import DaprInferenceClientBase
|
||
from dapr_agents.llm.utils import RequestHandler, ResponseHandler
|
||
from dapr_agents.prompt.base import PromptTemplateBase
|
||
from dapr_agents.prompt.prompty import Prompty
|
||
from dapr_agents.tool import AgentTool
|
||
from dapr_agents.types.exceptions import DaprRuntimeVersionNotSupportedError
|
||
from dapr_agents.types.message import (
|
||
BaseMessage,
|
||
LLMChatResponse,
|
||
)
|
||
from dapr_agents.utils import is_version_supported
|
||
|
||
|
||
# Lazy import to avoid import issues during test collection
|
||
def _import_conversation_types():
|
||
from dapr.clients.grpc.conversation import (
|
||
ConversationInputAlpha2,
|
||
ConversationMessage,
|
||
ConversationMessageOfAssistant,
|
||
ConversationMessageContent,
|
||
ConversationToolCalls,
|
||
ConversationToolCallsOfFunction,
|
||
create_user_message,
|
||
create_system_message,
|
||
create_assistant_message,
|
||
create_tool_message,
|
||
)
|
||
|
||
return (
|
||
ConversationInputAlpha2,
|
||
ConversationMessage,
|
||
ConversationMessageOfAssistant,
|
||
ConversationMessageContent,
|
||
ConversationToolCalls,
|
||
ConversationToolCallsOfFunction,
|
||
create_user_message,
|
||
create_system_message,
|
||
create_assistant_message,
|
||
create_tool_message,
|
||
)
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class DaprChatClient(DaprInferenceClientBase, ChatClientBase):
|
||
"""
|
||
Chat client for Dapr's Inference API.
|
||
|
||
Integrates Prompty-driven prompt templates, tool injection,
|
||
PII scrubbing, and normalizes the Dapr output into our unified
|
||
LLMChatResponse schema. **Streaming is not supported.**
|
||
"""
|
||
|
||
prompty: Optional[Prompty] = Field(
|
||
default=None, description="Optional Prompty instance for templating."
|
||
)
|
||
prompt_template: Optional[PromptTemplateBase] = Field(
|
||
default=None, description="Optional prompt-template to format inputs."
|
||
)
|
||
|
||
component_name: Optional[str] = None
|
||
|
||
# Support both function_call and json structured output modes
|
||
SUPPORTED_STRUCTURED_MODES: ClassVar[set[str]] = {"function_call", "json"}
|
||
|
||
def model_post_init(self, __context: Any) -> None:
|
||
"""
|
||
After Pydantic init, set up API/type and default LLM component from env.
|
||
"""
|
||
self._api = "chat"
|
||
self._llm_component = self.component_name
|
||
if not self._llm_component:
|
||
self._llm_component = os.environ.get("DAPR_LLM_COMPONENT_DEFAULT")
|
||
if not self._llm_component:
|
||
logger.debug(
|
||
"No LLM component provided and no default component found in the environment. Will try to get it from the metadata at runtime."
|
||
)
|
||
super().model_post_init(__context)
|
||
|
||
@classmethod
|
||
def from_prompty(
|
||
cls,
|
||
prompty_source: Union[str, Path],
|
||
timeout: Union[int, float, Dict[str, Any]] = 1500,
|
||
) -> "DaprChatClient":
|
||
"""
|
||
Build a DaprChatClient from a Prompty spec.
|
||
|
||
Args:
|
||
prompty_source: Path or inline Prompty YAML/JSON.
|
||
timeout: Request timeout in seconds or HTTPX-style dict.
|
||
|
||
Returns:
|
||
Configured DaprChatClient.
|
||
"""
|
||
prompty_instance = Prompty.load(prompty_source)
|
||
prompt_template = Prompty.to_prompt_template(prompty_instance)
|
||
return cls.model_validate(
|
||
{
|
||
"timeout": timeout,
|
||
"prompty": prompty_instance,
|
||
"prompt_template": prompt_template,
|
||
}
|
||
)
|
||
|
||
def translate_response(self, response: dict, model: str) -> dict:
|
||
"""
|
||
Convert Dapr Alpha2 response into OpenAI-style ChatCompletion dict.
|
||
"""
|
||
if not isinstance(response, dict):
|
||
logger.error(f"Invalid response type: {type(response)}")
|
||
raise ValueError(f"Response must be a dictionary, got {type(response)}")
|
||
|
||
# Flatten all output choices from Alpha2 envelope
|
||
choices: List[Dict[str, Any]] = []
|
||
outputs = response.get("outputs", []) or []
|
||
if not isinstance(outputs, list):
|
||
logger.error(f"Invalid outputs type: {type(outputs)}")
|
||
raise ValueError(f"Outputs must be a list, got {type(outputs)}")
|
||
|
||
for output in outputs:
|
||
if not isinstance(output, dict):
|
||
logger.error(f"Invalid output type: {type(output)}")
|
||
continue
|
||
output_choices = output.get("choices", []) or []
|
||
if not isinstance(output_choices, list):
|
||
logger.error(f"Invalid choices type: {type(output_choices)}")
|
||
continue
|
||
for choice in output_choices:
|
||
if not isinstance(choice, dict):
|
||
logger.error(f"Invalid choice type: {type(choice)}")
|
||
continue
|
||
# Ensure message is present and has required fields
|
||
message = choice.get("message", {})
|
||
if not isinstance(message, dict):
|
||
logger.error(f"Invalid message type: {type(message)}")
|
||
continue
|
||
# Add required fields if missing
|
||
if "content" not in message:
|
||
message["content"] = ""
|
||
if "role" not in message:
|
||
message["role"] = "assistant"
|
||
choice["message"] = message
|
||
choices.append(choice)
|
||
|
||
return {
|
||
"choices": choices,
|
||
"created": int(time.time()),
|
||
"model": model,
|
||
"object": "chat.completion",
|
||
"usage": {"total_tokens": "-1"},
|
||
}
|
||
|
||
def convert_to_conversation_inputs(self, inputs: List[Dict[str, Any]]) -> List[Any]:
|
||
"""
|
||
Map normalized messages into a single Alpha2 ConversationInput that preserves history.
|
||
|
||
Alpha2 expects a list of ConversationMessage entries inside one ConversationInputAlpha2
|
||
for a turn. If there are tool results, they must reference prior assistant tool_calls by id.
|
||
"""
|
||
# Lazy import conversation types
|
||
(
|
||
ConversationInputAlpha2,
|
||
ConversationMessage,
|
||
ConversationMessageOfAssistant,
|
||
ConversationMessageContent,
|
||
ConversationToolCalls,
|
||
ConversationToolCallsOfFunction,
|
||
create_user_message,
|
||
create_system_message,
|
||
create_assistant_message,
|
||
create_tool_message,
|
||
) = _import_conversation_types()
|
||
|
||
history_messages: List[ConversationMessage] = []
|
||
scrub_flags: List[bool] = []
|
||
|
||
for item in inputs:
|
||
role = item.get("role")
|
||
content = item.get("content", "")
|
||
|
||
if role == "user":
|
||
msg = create_user_message(content)
|
||
elif role == "system":
|
||
msg = create_system_message(content)
|
||
elif role == "assistant":
|
||
# Preserve assistant tool_calls if present (OpenAI-like schema)
|
||
tool_calls_data = item.get("tool_calls") or []
|
||
if tool_calls_data:
|
||
converted_calls: List[ConversationToolCalls] = []
|
||
for tc in tool_calls_data:
|
||
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
||
name = fn.get("name", "")
|
||
arguments = fn.get("arguments", "")
|
||
# Ensure arguments is a string
|
||
if not isinstance(arguments, str):
|
||
try:
|
||
import json as _json
|
||
|
||
arguments = _json.dumps(arguments)
|
||
except Exception:
|
||
arguments = str(arguments)
|
||
converted_calls.append(
|
||
ConversationToolCalls(
|
||
id=tc.get("id", None),
|
||
function=ConversationToolCallsOfFunction(
|
||
name=name, arguments=arguments
|
||
),
|
||
)
|
||
)
|
||
msg = ConversationMessage(
|
||
of_assistant=ConversationMessageOfAssistant(
|
||
content=[ConversationMessageContent(text=content)],
|
||
tool_calls=converted_calls,
|
||
)
|
||
)
|
||
else:
|
||
msg = create_assistant_message(content)
|
||
elif role == "tool":
|
||
tool_id = item.get("tool_call_id") or item.get("id") or ""
|
||
name = item.get("name", "")
|
||
msg = create_tool_message(tool_id, name, content)
|
||
else:
|
||
raise ValueError(f"Unsupported role for Alpha2 conversion: {role}")
|
||
|
||
history_messages.append(msg)
|
||
scrub_flags.append(bool(item.get("scrubPII")))
|
||
|
||
# Use scrub_pii if any message requested it
|
||
scrub_any = any(scrub_flags) if scrub_flags else None
|
||
|
||
return [ConversationInputAlpha2(messages=history_messages, scrub_pii=scrub_any)]
|
||
|
||
def generate(
|
||
self,
|
||
messages: Union[
|
||
str,
|
||
Dict[str, Any],
|
||
BaseMessage,
|
||
Iterable[Union[Dict[str, Any], BaseMessage]],
|
||
] = None,
|
||
*,
|
||
input_data: Optional[Dict[str, Any]] = None,
|
||
llm_component: Optional[str] = None,
|
||
tools: Optional[List[Union[AgentTool, Dict[str, Any]]]] = None,
|
||
response_format: Optional[Type[BaseModel]] = None,
|
||
structured_mode: Literal["function_call", "json"] = "json",
|
||
scrubPII: bool = False,
|
||
temperature: Optional[float] = None,
|
||
**kwargs: Any,
|
||
) -> Union[
|
||
LLMChatResponse,
|
||
BaseModel,
|
||
List[BaseModel],
|
||
]:
|
||
"""
|
||
Issue a non-streaming chat completion via Dapr.
|
||
|
||
- **Streaming is not supported** and setting `stream=True` will raise.
|
||
- Returns a unified `LLMChatResponse` (if no `response_format`), or
|
||
validated Pydantic model(s) when `response_format` is provided.
|
||
|
||
Args:
|
||
messages: Prebuilt messages or None to use `input_data`.
|
||
input_data: Variables for Prompty template rendering.
|
||
llm_component: Dapr component name (defaults from env).
|
||
tools: AgentTool or dict specifications.
|
||
response_format: Pydantic model for structured output.
|
||
structured_mode: "json" (default) or "function_call".
|
||
scrubPII: Obfuscate sensitive output if True.
|
||
temperature: Sampling temperature.
|
||
**kwargs: Other Dapr API parameters.
|
||
|
||
Returns:
|
||
• `LLMChatResponse` if no `response_format`
|
||
• Pydantic model (or `List[...]`) when `response_format` is set
|
||
|
||
Raises:
|
||
ValueError: on invalid `structured_mode`, missing inputs, or if `stream=True`.
|
||
"""
|
||
# 1) Validate structured_mode
|
||
if structured_mode not in self.SUPPORTED_STRUCTURED_MODES:
|
||
raise ValueError(
|
||
f"structured_mode must be one of {self.SUPPORTED_STRUCTURED_MODES}"
|
||
)
|
||
# 2) Disallow streaming
|
||
# Note: response_format is now supported for structured output
|
||
if kwargs.get("stream"):
|
||
raise ValueError("Streaming is not supported by DaprChatClient.")
|
||
|
||
# 3) Build messages via Prompty
|
||
if input_data:
|
||
if not self.prompt_template:
|
||
raise ValueError("input_data provided but no prompt_template is set.")
|
||
messages = self.prompt_template.format_prompt(**input_data)
|
||
|
||
if not messages:
|
||
raise ValueError("Either 'messages' or 'input_data' must be provided.")
|
||
|
||
# 4) Normalize + merge defaults
|
||
params: Dict[str, Any] = {
|
||
"inputs": RequestHandler.normalize_chat_messages(messages)
|
||
}
|
||
if self.prompty:
|
||
params = {**self.prompty.model.parameters.model_dump(), **params, **kwargs}
|
||
else:
|
||
params.update(kwargs)
|
||
|
||
# 5) Inject tools + structured directives
|
||
params = RequestHandler.process_params(
|
||
params,
|
||
llm_provider=self.provider,
|
||
tools=tools,
|
||
response_format=response_format,
|
||
structured_mode=structured_mode,
|
||
)
|
||
|
||
logger.debug(f"Processed parameters for Dapr: {params}")
|
||
if response_format:
|
||
logger.debug(f"Response format: {response_format}")
|
||
logger.debug(f"Structured mode: {structured_mode}")
|
||
|
||
# 6) Convert to Dapr inputs & call
|
||
conv_inputs = self.convert_to_conversation_inputs(params["inputs"])
|
||
try:
|
||
logger.debug("Invoking the Dapr Conversation API.")
|
||
# Log tools/tool_choice/parameters for debugging
|
||
if params.get("tools"):
|
||
try:
|
||
logger.debug(
|
||
f"Alpha2 tools payload: {[t.get('function', {}).get('name', '') for t in params['tools'] if isinstance(t, dict)]}"
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
"Alpha2 tools payload present (could not render names)."
|
||
)
|
||
if params.get("tool_choice") is not None:
|
||
logger.debug(f"Alpha2 tool_choice: {params.get('tool_choice')}")
|
||
if params.get("parameters") is not None:
|
||
logger.debug(
|
||
f"Alpha2 parameters keys: {list(params.get('parameters', {}).keys())}"
|
||
)
|
||
# get metadata information from the dapr client
|
||
metadata = self.client.get_metadata()
|
||
_check_dapr_runtime_support(metadata)
|
||
|
||
llm_component = llm_component or self._llm_component
|
||
if not llm_component:
|
||
llm_component = _get_llm_component(metadata)
|
||
|
||
# Extract additional API parameters (response_format is sent via the
|
||
# dedicated response_format field, not duplicated here)
|
||
api_params = {}
|
||
if "structured_mode" in params:
|
||
api_params["structured_mode"] = str(params["structured_mode"])
|
||
|
||
# Convert tool_choice from dict format to string of 'none', 'auto', or 'required'
|
||
tool_choice_param = params.get("tool_choice")
|
||
if isinstance(tool_choice_param, dict):
|
||
# When a specific function is requested (dict format), use 'required'
|
||
# to force tool calling, since Dapr doesn't support function-specific selection
|
||
tool_choice_param = "required"
|
||
elif isinstance(tool_choice_param, str) and tool_choice_param not in (
|
||
"none",
|
||
"auto",
|
||
"required",
|
||
):
|
||
# If it's a string but not a valid Dapr value (e.g., a function name),
|
||
# default to 'required' to force tool calling
|
||
logger.warning(
|
||
f"tool_choice value '{tool_choice_param}' is not supported by Dapr. "
|
||
"Using 'required' instead."
|
||
)
|
||
tool_choice_param = "required"
|
||
|
||
raw = self.client.chat_completion_alpha2(
|
||
llm=llm_component or self._llm_component,
|
||
inputs=conv_inputs,
|
||
scrub_pii=scrubPII,
|
||
temperature=temperature,
|
||
tools=params.get("tools"),
|
||
tool_choice=tool_choice_param,
|
||
parameters=api_params or None,
|
||
response_format=_to_dapr_response_format(params.get("response_format")),
|
||
)
|
||
normalized = self.translate_response(
|
||
raw, llm_component or self._llm_component
|
||
)
|
||
logger.debug(f"Dapr Conversation API response: {raw}")
|
||
logger.debug(f"Normalized response: {normalized}")
|
||
except Exception as e:
|
||
logger.warning(f"Dapr Conversation API call failed: {e}")
|
||
raise
|
||
|
||
# 7) Hand off to our unified handler (always non‐stream)
|
||
return ResponseHandler.process_response(
|
||
response=normalized,
|
||
llm_provider=self.provider,
|
||
response_format=response_format,
|
||
structured_mode=structured_mode,
|
||
stream=False,
|
||
)
|
||
|
||
|
||
def _simplify_anyof(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
Recursively collapse ``anyOf`` unions into a single ``type`` field.
|
||
|
||
The Dapr runtime's ``convertToStructuredOutputSchema`` (Go) requires every
|
||
sub-schema to have a ``"type"`` string. Pydantic emits
|
||
``anyOf: [{"type": "X"}, {"type": "null"}]`` for ``Optional[X]`` fields,
|
||
which has *no* top-level ``"type"`` and causes a conversion error that
|
||
crashes the sidecar (nil-logger panic on the error path).
|
||
|
||
This helper rewrites such patterns to ``{"type": "X"}`` (dropping the
|
||
null variant) so the Go code can process the schema safely.
|
||
"""
|
||
result = dict(schema)
|
||
|
||
# Collapse anyOf: [{"type": T}, {"type": "null"}] → {"type": T}
|
||
if "anyOf" in result and "type" not in result:
|
||
variants = result.get("anyOf", [])
|
||
non_null = [
|
||
v for v in variants if isinstance(v, dict) and v.get("type") != "null"
|
||
]
|
||
if len(non_null) == 1:
|
||
collapsed = dict(non_null[0])
|
||
# Preserve sibling keys (description, title, default, etc.)
|
||
for k, v in result.items():
|
||
if k != "anyOf" and k not in collapsed:
|
||
collapsed[k] = v
|
||
result = collapsed
|
||
|
||
# Recurse into object properties
|
||
if result.get("type") == "object" and "properties" in result:
|
||
result["properties"] = {
|
||
k: _simplify_anyof(v) if isinstance(v, dict) else v
|
||
for k, v in result["properties"].items()
|
||
}
|
||
|
||
# Recurse into array items
|
||
if result.get("type") == "array" and isinstance(result.get("items"), dict):
|
||
result["items"] = _simplify_anyof(result["items"])
|
||
|
||
return result
|
||
|
||
|
||
def _to_dapr_response_format(
|
||
oai_format: Optional[Dict[str, Any]],
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Convert an OpenAI-style response_format dict to the flat JSON schema format
|
||
expected by the Dapr runtime's convertToStructuredOutputDefinition.
|
||
|
||
OAI format: {"type": "json_schema", "json_schema": {"name": ..., "description": ..., "strict": ..., "schema": {<json-schema>}}}
|
||
Dapr format: {<json-schema fields>, "name": ..., "description": ..., "strict": ...}
|
||
"""
|
||
if oai_format is None:
|
||
return None
|
||
if oai_format.get("type") == "json_schema":
|
||
inner = oai_format.get("json_schema", {})
|
||
schema = inner.get("schema", {})
|
||
simplified = _simplify_anyof(schema)
|
||
return {
|
||
**simplified,
|
||
"name": inner.get("name", "response"),
|
||
"description": inner.get("description", ""),
|
||
"strict": inner.get("strict", False),
|
||
}
|
||
return oai_format
|
||
|
||
|
||
def _check_dapr_runtime_support(metadata: "GetMetadataResponse"): # noqa: F821
|
||
"""Check if the Dapr runtime version is supported for Alpha2 Chat Client."""
|
||
extended_metadata = metadata.extended_metadata
|
||
dapr_runtime_version = extended_metadata.get("daprRuntimeVersion", None)
|
||
if dapr_runtime_version is not None:
|
||
# Allow only versions >=1.16.0, edge, and <2.0.0 for Alpha2 Chat Client
|
||
if not is_version_supported(str(dapr_runtime_version), ">=1.16.0, <2.0.0"):
|
||
raise DaprRuntimeVersionNotSupportedError(
|
||
f"!!!!! Dapr Runtime Version {dapr_runtime_version} is not supported with Alpha2 Dapr Chat Client. Only Dapr runtime versions >=1.16.0 and <2.0.0 are supported."
|
||
)
|
||
|
||
|
||
def _get_llm_component(metadata: "GetMetadataResponse") -> str: # noqa: F821
|
||
"""Get the LLM component from the metadata."""
|
||
conversation_components = [
|
||
component
|
||
for component in metadata.registered_components
|
||
if component.type.startswith("conversation.")
|
||
]
|
||
if len(conversation_components) == 1:
|
||
return conversation_components[0].name
|
||
elif len(conversation_components) > 1:
|
||
raise ValueError(
|
||
"Multiple LLM components found in the metadata. Please provide the component name explicitly (e.g. llm = DaprChatClient(component_name='openai')) or environment variable DAPR_LLM_COMPONENT_DEFAULT."
|
||
)
|
||
else:
|
||
raise ValueError(
|
||
"No LLM component provided and no default component found in the metadata."
|
||
)
|