Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api/agent/core/prompt_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ def _get_sqlite_guidance() -> str:
"## SQLite Data\n\n"
"Fetch new data with its source tool and answer small results directly. Use sqlite_batch for data already in "
"SQLite when large/truncated or needing filtering, joins, aggregation, charts, reuse, or domain logic. Model "
"reusable domains once as shared entity, event, and relationship tables. Separate repeating parents from children "
"reusable domains as keyed entities, events, and relations; query unmatched inventory rows before reporting. Normalize parent/child data "
"(vendors/plans, accounts/events) with PRIMARY KEY/UNIQUE identity, useful indexes, and source provenance; put "
"logic in SQL and return only needed rows to context. Populate them from all relevant __tool_results rows with one "
"shaped INSERT ... SELECT/json_each query and an IN/tool_name filter; never filter result_id one at a time; never "
Expand Down Expand Up @@ -3205,7 +3205,7 @@ def _get_web_chat_formatting_guidance() -> str:
return (
"Web chat and peer DM formatting:\n"
"Start with the answer/main finding. Keep simple exchanges/outreach natural. "
"Reports to owners/creators and multi-part findings, metrics, or recommendations use polished sections, bullets/tables or metric blocks, status labels, and tasteful visual cues, even when not called a report. "
"Owner/creator reports and multi-part findings need polished hierarchy: sections, bullets, compact linked tables, metric/status blocks, or tasteful cues, even without a report label. "
"Address known recipients naturally once around actions; avoid generic delivery logs and agent-name self-intros unless asked. "
"Use whitespace, not separators. Charts: paste create_chart result.inline; don't attach/read/rebuild."
)
Expand Down Expand Up @@ -3767,7 +3767,7 @@ def _get_system_instruction(
"Do not invent work, results, preferences, or personal experiences.\n\n"

"## Output Rules\n\n"
"Use the lightest clear structure: fact, list, table, or sectioned report. Ground facts, numbers, units, and URLs in tool results; do not relabel or convert units unless asked. For record lists, include item/detail URLs from results, not only feed/source URLs. Present requested returned data directly; omit unrelated fields and unavailable extras, summarize overflow, and do not add follow-up offers after simple facts, prices, statuses, or quick lookups. "
"Use proportionate structure. A bounded named set is a coverage contract: include each item or mark it unavailable/excluded; summarize only real overflow. Ground facts, numbers, units, and URLs in tool results; never relabel/convert units unless asked. Record lists keep item/detail URLs, not just feed/source URLs. Present requested data directly; omit unrelated/unavailable fields and follow-up offers after simple facts, prices, statuses, or lookups. "
"Charts: create only when requested/materially useful. "
"Paste create_chart result.inline/result.inline_html in the message; do not attach/read charts or invent paths, hashes, image tags, or <img> URLs. "
"Use create_csv for tabular exports, create_pdf for PDFs, and create_file for other text/doc formats; create_file query mode must return exactly one row and one column.\n\n"
Expand Down
20 changes: 14 additions & 6 deletions api/agent/files/filespace_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from dataclasses import dataclass
from typing import Any, List

from botocore.exceptions import BotoCoreError, ClientError
from celery.utils.log import get_task_logger
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.base import ContentFile
from django.db import IntegrityError, transaction
from django.db import DatabaseError, IntegrityError, transaction
from google.api_core.exceptions import GoogleAPIError

from util.analytics import Analytics, AnalyticsEvent, AnalyticsSource

Expand All @@ -21,6 +23,8 @@
EXPORTS_DIR_NAME = "exports"
DOWNLOADS_DIR_NAME = "downloads"
UNSAFE_FILESPACE_PART_CHARS_RE = re.compile(r"(?u)[^-\w.+@]")
FILESPACE_STORAGE_UNAVAILABLE = "Filespace storage unavailable. Retry this file tool once; do not use another file tool, Python, or shell. After another failure, report the blocker or return a small result inline."
FILESPACE_PERSISTENCE_ERRORS = (BotoCoreError, ClientError, DatabaseError, GoogleAPIError, OSError, SuspiciousFileOperation)


@dataclass
Expand Down Expand Up @@ -171,21 +175,25 @@ def _save_node_content(
*,
delete_node_on_failure: bool,
) -> dict[str, Any] | None:
previous_content_name = getattr(node.content, "name", None)
try:
node.content.save(node.name, ContentFile(content_bytes), save=False)
node.save()
node.refresh_from_db()
return None
except Exception:
except FILESPACE_PERSISTENCE_ERRORS:
logger.exception("Failed to persist file to %s for agent %s", dir_name, agent_id)
try:
if node.content and getattr(node.content, "name", None):
if node.content and getattr(node.content, "name", None) != previous_content_name:
node.content.delete(save=False)
except Exception:
except FILESPACE_PERSISTENCE_ERRORS:
logger.exception("Failed to clean up file content for node %s", node.id)
if delete_node_on_failure:
node.delete()
return {"status": "error", "message": "Failed to save the file in the filespace."}
try:
node.delete()
except DatabaseError:
logger.exception("Failed to clean up file node %s", node.id)
return {"status": "error", "code": "filespace_storage_unavailable", "message": FILESPACE_STORAGE_UNAVAILABLE, "retryable": True}


def _agent_has_access(agent: "PersistentAgent", filespace_id: "uuid.UUID") -> bool:
Expand Down
14 changes: 5 additions & 9 deletions api/agent/tools/create_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
from api.agent.tools.file_export_helpers import resolve_export_target, write_agent_export
from .sqlite_query_runner import run_sqlite_select

EXTENSION = ".csv"
MIME_TYPE = "text/csv"
MAX_EXPORT_ROWS = 5000


Expand All @@ -18,7 +16,7 @@ def get_create_csv_tool() -> Dict[str, Any]:
"name": "create_csv",
"description": (
"Create a CSV file and store it in the agent filespace. "
"Provide either raw CSV text or a SQLite SELECT query to export query results. "
"Provide exactly one content source: raw CSV text, or a SQLite SELECT query for data already in SQLite. "
"Recommended path: /exports/your-file.csv. Returns `file`, `inline`, `inline_html`, and `attach`."
),
"parameters": {
Expand Down Expand Up @@ -58,10 +56,8 @@ def execute_create_csv(agent: PersistentAgent, params: Dict[str, Any]) -> Dict[s
csv_text = params.get("csv_text")
query = params.get("query")

if not csv_text and not query:
return {"status": "error", "message": "Provide either csv_text or query."}
if csv_text and query:
return {"status": "error", "message": "Use csv_text OR query, not both."}
if bool(csv_text) == bool(query):
return {"status": "error", "message": "Provide exactly one of csv_text or query."}

path, overwrite, error = resolve_export_target(params, agent_id=agent.id)
if error:
Expand Down Expand Up @@ -92,8 +88,8 @@ def execute_create_csv(agent: PersistentAgent, params: Dict[str, Any]) -> Dict[s
return write_agent_export(
agent=agent,
content_bytes=csv_text_to_write.encode("utf-8"),
extension=EXTENSION,
mime_type=MIME_TYPE,
extension=".csv",
mime_type="text/csv",
path=path,
overwrite=overwrite,
size_label="CSV",
Expand Down
8 changes: 4 additions & 4 deletions api/agent/tools/tests/test_attachment_guidance.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ def test_report_message_guidance_names_visual_quality_without_eval_prompting(sel
self.assertIn("styled tables or metric blocks", email_tool["function"]["parameters"]["properties"]["mobile_first_html"]["description"])
self.assertIn("false when this email is the requested final delivery", email_tool["function"]["parameters"]["properties"]["will_continue_work"]["description"])
self.assertIn("Do not use this to simulate or confirm an email/SMS delivery", chat_tool["function"]["description"])
self.assertIn("status labels", chat_guidance)
self.assertIn("owners/creators", chat_guidance)
self.assertIn("even when not called a report", chat_guidance)
self.assertIn("tables or metric blocks", chat_guidance)
self.assertIn("metric/status blocks", chat_guidance)
self.assertIn("Owner/creator reports", chat_guidance)
self.assertIn("even without a report label", chat_guidance)
self.assertIn("compact linked tables", chat_guidance)
self.assertIn("Address known recipients naturally once", chat_guidance)
self.assertIn("agent-name self-intros", chat_guidance)
self.assertIn("owners/creators", chat_tool["function"]["parameters"]["properties"]["body"]["description"])
Expand Down
74 changes: 56 additions & 18 deletions api/evals/scenarios/behavior_micro.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import csv
import io
import json
from copy import deepcopy
from dataclasses import dataclass, field
import json

from api.agent.comms.human_input_requests import MAX_OPTION_COUNT, dismiss_human_input_request
from api.agent.core.processing_flags import get_human_inbound_generation
Expand All @@ -18,6 +20,7 @@
sqlite_batch_mutates_planning_state,
)
from api.models import (
AgentFsNode,
EvalRunTask,
CommsAllowlistEntry,
CommsChannel,
Expand Down Expand Up @@ -2228,7 +2231,7 @@ def run(self, run_id, agent_id):
@register_scenario
class ToolChoiceCsvDeliverableUsesCreateCsvScenario(BehaviorMicroScenario):
slug = TOOL_CHOICE_CSV_DELIVERABLE_USES_CREATE_CSV
description = "A downloadable CSV request should use create_csv."
description = "A downloadable CSV request should execute create_csv and persist the requested rows."
category = "files"
tags = ("agent_behavior", "micro", "tool_choice", "files")
tasks = [
Expand All @@ -2241,15 +2244,6 @@ def run(self, run_id, agent_id):
self._seed_prior_processing_run(agent_id)
self._enable_builtin_tools(agent_id, ["create_csv"])

mock_config = {
"create_csv": {
"status": "ok",
"file": {"path": "/exports/q1-leads.csv"},
"message": "CSV created.",
},
"create_file": {"status": "error", "message": "Use create_csv for CSV deliverables."},
}

self.record_task_result(run_id, None, EvalRunTask.Status.RUNNING, task_name="inject_prompt")
with self.wait_for_agent_idle(agent_id, timeout=120):
inbound = self.inject_message(
Expand All @@ -2260,10 +2254,12 @@ def run(self, run_id, agent_id):
),
trigger_processing=True,
eval_run_id=run_id,
mock_config=mock_config,
eval_stop_policy={
"stop_on_tool_names": ["create_file"],
"stop_when_all_seen": [{"tool_name": "create_csv"}],
"stop_on_tool_names_after_execution": ["create_csv"],
"stop_on_unexpected_relevant_tool": True,
"allowed_tool_names": ["create_csv"],
"ignored_tool_names": ["update_plan", "send_chat_message", "sleep_until_next_trigger"],
"max_relevant_tool_calls": 3,
},
)
self.record_task_result(
Expand All @@ -2277,22 +2273,64 @@ def run(self, run_id, agent_id):

self.record_task_result(run_id, None, EvalRunTask.Status.RUNNING, task_name="verify_create_csv")
create_csv_calls = get_tool_calls_for_run(run_id, after=inbound.timestamp, tool_names={"create_csv"})
if create_csv_calls:
successful_calls = []
for call in create_csv_calls:
result = call.result
if isinstance(result, str):
try:
result = json.loads(result)
except json.JSONDecodeError:
result = {}
if (
str(call.status or "").lower() == "complete"
and isinstance(result, dict)
and result.get("status") == "ok"
and result.get("file") == "$[/exports/q1-leads.csv]"
):
successful_calls.append(call)

node = AgentFsNode.objects.filter(
created_by_agent_id=agent_id,
path="/exports/q1-leads.csv",
).alive().first()
rows = []
read_error = None
if node is None or not node.content.name:
read_error = "missing content"
else:
try:
with node.content.open("rb") as handle:
rows = list(csv.reader(io.StringIO(handle.read().decode("utf-8"))))
except (OSError, ValueError, UnicodeDecodeError, csv.Error) as exc:
read_error = exc.__class__.__name__

expected_rows = [
["company", "priority"],
["Acme", "high"],
["Globex", "medium"],
["Initech", "low"],
]
if len(successful_calls) == 1 and len(create_csv_calls) == 1 and read_error is None and rows == expected_rows:
self.record_task_result(
run_id,
None,
EvalRunTask.Status.PASSED,
task_name="verify_create_csv",
observed_summary="Agent used create_csv for the CSV deliverable.",
artifacts={"step": create_csv_calls[0].step},
observed_summary="Agent executed create_csv once and persisted the requested header and three rows.",
artifacts={"step": successful_calls[0].step},
)
else:
self.record_task_result(
run_id,
None,
EvalRunTask.Status.FAILED,
task_name="verify_create_csv",
observed_summary="Agent did not use create_csv for the CSV deliverable.",
observed_summary=(
f"Expected one successful create_csv and exact persisted rows; saw "
f"{len(create_csv_calls)} call(s), {len(successful_calls)} success(es), "
f"read_error={read_error}, rows={rows}."
),
artifacts={"step": create_csv_calls[0].step} if create_csv_calls else {},
)


Expand Down
8 changes: 7 additions & 1 deletion api/evals/scenarios/effort_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2473,6 +2473,12 @@ class EffortSimpleCurrentCompanyReportScenario(EffortCalibrationScenario):
ScenarioTask(name="verify_no_config_churn", assertion_type="manual"),
ScenarioTask(name="verify_turn_budget", assertion_type="manual"),
]
required_concept_groups = (
("Northstar Robotics", "Northstar"),
("Atlas", "mixed-fleet", "mixed fleet"),
("Series B", "$42M", "42M", "42 million"),
("18 percent", "18%", "eighteen percent"),
)

def run(self, run_id: str, agent_id: str) -> None:
self._ready_agent(agent_id)
Expand Down Expand Up @@ -2682,7 +2688,7 @@ def run(self, run_id: str, agent_id: str) -> None:
min_source_count=2,
min_chars=650,
max_chars=3000,
required_any_groups=(("Northstar Robotics", "Northstar"),),
required_any_groups=self.required_concept_groups,
)
self._record_no_question_battery(
run_id,
Expand Down
Loading
Loading