diff --git a/Dockerfile b/Dockerfile index 2fee7e57..a8867099 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,7 @@ RUN set -eux; \ esac; \ apt-get update && \ # Install curl, Python, and PostgreSQL client libraries - apt-get install -y curl python3 python3-venv libpq-dev redis-server && \ + apt-get install -y curl python3 python3-venv libpq-dev redis-server git && \ # Install Node.js curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \ apt-get install -y nodejs && \ @@ -71,6 +71,9 @@ RUN set -eux; \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +# Configure git to ignore ownership checks for mounted repos (safe.directory fix) +RUN git config --global --add safe.directory '*' + # Add OpenTelemetry Collector configs COPY otel/otel-collector-config-file.yaml /etc/otel/config-file.yaml COPY otel/otel-collector-config-clickhouse.yaml /etc/otel/config-clickhouse.yaml diff --git a/compose.yaml b/compose.yaml index 756919c2..a06cd483 100644 --- a/compose.yaml +++ b/compose.yaml @@ -50,6 +50,10 @@ services: - CLICKHOUSE_DATABASE=${CLICKHOUSE_DATABASE} - CLICKHOUSE_USERNAME=${CLICKHOUSE_USERNAME} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD} + # Git safe.directory config (backup to Dockerfile fix for dubious ownership) + - GIT_CONFIG_COUNT=1 + - GIT_CONFIG_KEY_0=safe.directory + - GIT_CONFIG_VALUE_0=* # volumes: # - ./configs/server_config.yaml:/root/.letta/config # config file # - ~/.letta/credentials:/root/.letta/credentials # credentials file diff --git a/dev-compose.yaml b/dev-compose.yaml index f9fd8e77..05b4692e 100644 --- a/dev-compose.yaml +++ b/dev-compose.yaml @@ -46,3 +46,7 @@ services: - VLLM_API_BASE=${VLLM_API_BASE} - OPENLLM_AUTH_TYPE=${OPENLLM_AUTH_TYPE} - OPENLLM_API_KEY=${OPENLLM_API_KEY} + # Git safe.directory config (backup to Dockerfile fix for dubious ownership) + - GIT_CONFIG_COUNT=1 + - GIT_CONFIG_KEY_0=safe.directory + - GIT_CONFIG_VALUE_0=* diff --git a/letta/agents/helpers.py b/letta/agents/helpers.py index efc8f37f..9532ff0a 100644 --- a/letta/agents/helpers.py +++ b/letta/agents/helpers.py @@ -12,7 +12,7 @@ from letta.helpers.datetime_helpers import get_utc_time from letta.log import get_logger from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState -from letta.schemas.enums import MessageRole +from letta.schemas.enums import MessageRole, RunStatus from letta.schemas.letta_message import MessageType from letta.schemas.letta_message_content import TextContent from letta.schemas.letta_response import LettaResponse @@ -300,7 +300,31 @@ async def _prepare_in_context_messages_no_persist_async( else: # User is trying to send a regular message if current_in_context_messages and current_in_context_messages[-1].is_approval_request(): - raise PendingApprovalError(pending_request_id=current_in_context_messages[-1].id) + # Check if the run associated with this approval request is still active + # If the run was cancelled/failed, the approval is orphaned and should be skipped + approval_msg = current_in_context_messages[-1] + approval_run_id = approval_msg.run_id + is_orphaned_approval = False + + if approval_run_id: + try: + from letta.services.run_manager import RunManager + + run_manager = RunManager() + approval_run = await run_manager.get_run_by_id(run_id=approval_run_id, actor=actor) + # Note: completed runs may still have valid approvals (stop_reason=requires_approval) + if approval_run.status in [RunStatus.cancelled, RunStatus.failed]: + logger.info( + f"Skipping orphaned approval request {approval_msg.id} - associated run {approval_run_id} " + f"has status {approval_run.status.value}" + ) + is_orphaned_approval = True + except Exception as e: + # If we can't check the run status, be conservative and raise the error + logger.warning(f"Failed to check run status for approval request {approval_msg.id}: {e}") + + if not is_orphaned_approval: + raise PendingApprovalError(pending_request_id=approval_msg.id) # Create a new user message from the input but dont store it yet new_in_context_messages = await create_input_messages( diff --git a/letta/agents/letta_agent_v3.py b/letta/agents/letta_agent_v3.py index 3a77c011..520fa3e4 100644 --- a/letta/agents/letta_agent_v3.py +++ b/letta/agents/letta_agent_v3.py @@ -1096,7 +1096,7 @@ class LettaAgentV3(LettaAgentV2): ) # update metrics self._update_global_usage_stats(llm_adapter.usage) - self.context_token_estimate = llm_adapter.usage.total_tokens + self.context_token_estimate = llm_adapter.usage.prompt_tokens self.logger.info(f"Context token estimate after LLM request: {self.context_token_estimate}") # Extract logprobs if present (for RL training) diff --git a/letta/helpers/json_helpers.py b/letta/helpers/json_helpers.py index a782acf1..38c24e72 100644 --- a/letta/helpers/json_helpers.py +++ b/letta/helpers/json_helpers.py @@ -51,25 +51,46 @@ def sanitize_unicode_surrogates(value: Any) -> Any: return value -def sanitize_control_characters(value: Any) -> Any: - """Recursively remove ASCII control characters (0x00-0x1F) from strings, - preserving tab (0x09), newline (0x0A), and carriage return (0x0D). +_UNICODE_TO_ASCII = { + "\u2014": "--", # em-dash + "\u2013": "-", # en-dash + "\u2012": "-", # figure dash + "\u2010": "-", # hyphen + "\u2011": "-", # non-breaking hyphen + "\u201c": '"', # left double quotation mark + "\u201d": '"', # right double quotation mark + "\u2018": "'", # left single quotation mark + "\u2019": "'", # right single quotation mark + "\u201a": ",", # single low-9 quotation mark + "\u201e": '"', # double low-9 quotation mark + "\u2026": "...", # horizontal ellipsis + "\u00a0": " ", # non-breaking space + "\u00ad": "", # soft hyphen (invisible, strip) +} - Some inference backends (e.g. Fireworks AI) perform strict JSON parsing on - the request body and reject payloads containing unescaped control characters. - Python's json.dumps will escape these, but certain proxy layers may - double-parse or re-serialize in ways that expose the raw bytes. + +def sanitize_control_characters(value: Any) -> Any: + """Recursively sanitize strings for strict ASCII-only JSON backends (e.g. Synthetic). + + Removes ASCII control characters (0x00-0x1F) except tab/newline/CR. + Replaces common non-ASCII typography (em-dash, curly quotes, ellipsis, etc.) + with ASCII equivalents. Strips remaining non-ASCII chars (> 0x7E) that would + appear as raw multi-byte UTF-8 sequences in the request body and cause parse + failures on backends that expect ASCII-safe JSON. This function sanitizes: - - Strings: strips control characters except whitespace (tab, newline, CR) + - Strings: replaces/strips non-ASCII; strips control chars except whitespace - Dicts: recursively sanitizes all string values - Lists: recursively sanitizes all elements - Other types: returned as-is """ if isinstance(value, str): + # Replace known typographic Unicode with ASCII equivalents first + for uni, asc in _UNICODE_TO_ASCII.items(): + value = value.replace(uni, asc) return "".join( char for char in value - if ord(char) >= 0x20 # printable + if ord(char) <= 0x7E # printable ASCII only or char in ("\t", "\n", "\r") # allowed whitespace ) elif isinstance(value, dict): diff --git a/letta/llm_api/openai_client.py b/letta/llm_api/openai_client.py index dd143374..760943d2 100644 --- a/letta/llm_api/openai_client.py +++ b/letta/llm_api/openai_client.py @@ -454,6 +454,15 @@ class OpenAIClient(LLMClientBase): ) request_data = data.model_dump(exclude_unset=True, exclude_none=True) + + # Strip reasoning fields (see streaming build_request_data for explanation) + _REASONING_FIELDS = ("reasoning_content", "reasoning_content_signature", + "redacted_reasoning_content", "omitted_reasoning_content") + if "messages" in request_data: + for message in request_data["messages"]: + for field in _REASONING_FIELDS: + message.pop(field, None) + return request_data @trace_method @@ -641,6 +650,15 @@ class OpenAIClient(LLMClientBase): tool.function.strict = False request_data = data.model_dump(exclude_unset=True, exclude_none=True) + # Strip reasoning fields that strict backends (Fireworks/Synthetic) reject. + # exclude_none handles fields that are None, but reasoning_content has actual + # text from previous assistant turns and must be explicitly removed. + _REASONING_FIELDS = ("reasoning_content", "reasoning_content_signature", + "redacted_reasoning_content", "omitted_reasoning_content") + if "messages" in request_data: + for message in request_data["messages"]: + for field in _REASONING_FIELDS: + message.pop(field, None) # If Ollama # if llm_config.handle.startswith("ollama/") and llm_config.enable_reasoner: diff --git a/letta/services/block_manager_git.py b/letta/services/block_manager_git.py index d7a7049a..649e0214 100644 --- a/letta/services/block_manager_git.py +++ b/letta/services/block_manager_git.py @@ -109,7 +109,9 @@ class GitEnabledBlockManager(BlockManager): block = result.scalar_one_or_none() if block: - # Update existing block + # Update existing block only if content changed + if block.value == value: + return block.to_pydantic() block.value = value if description is not None: block.description = description diff --git a/letta/services/memory_repo/memfs_client_base.py b/letta/services/memory_repo/memfs_client_base.py index 1c749074..d4b83cd7 100644 --- a/letta/services/memory_repo/memfs_client_base.py +++ b/letta/services/memory_repo/memfs_client_base.py @@ -51,7 +51,7 @@ class MemfsClient: """ self.local_path = local_path or DEFAULT_LOCAL_PATH self.storage = LocalStorageBackend(base_path=self.local_path) - self.git = GitOperations(storage=self.storage, redis_client=None) + self.git = GitOperations(storage=self.storage) logger.info(f"MemfsClient initialized with local storage at {self.local_path}")