evalrun 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. agents/__init__.py +6 -0
  2. agents/auditor/__init__.py +13 -0
  3. agents/auditor/budget_auditor.py +91 -0
  4. agents/auditor/parser.py +139 -0
  5. agents/auditor/prompts.py +64 -0
  6. agents/auditor/schema.py +67 -0
  7. agents/base.py +20 -0
  8. agents/reflection/__init__.py +3 -0
  9. agents/reflection/agent.py +77 -0
  10. agents/reflection/prompts.py +18 -0
  11. agents/research/__init__.py +4 -0
  12. agents/research/agent.py +45 -0
  13. agents/research/planner.py +52 -0
  14. agents/research/prompts.py +14 -0
  15. agents/support/__init__.py +5 -0
  16. agents/support/triage_agent.py +45 -0
  17. agents/travel/__init__.py +11 -0
  18. agents/travel/agent.py +377 -0
  19. agents/travel/prompts.py +30 -0
  20. agents/travel/session.py +110 -0
  21. cli/__init__.py +6 -0
  22. cli/demo.py +47 -0
  23. cli/formatter.py +93 -0
  24. cli/html_reporter.py +647 -0
  25. cli/main.py +423 -0
  26. cli/progress.py +38 -0
  27. cli/resolver.py +99 -0
  28. evalrun-0.4.0.dist-info/METADATA +268 -0
  29. evalrun-0.4.0.dist-info/RECORD +100 -0
  30. evalrun-0.4.0.dist-info/WHEEL +5 -0
  31. evalrun-0.4.0.dist-info/entry_points.txt +2 -0
  32. evalrun-0.4.0.dist-info/licenses/LICENSE +0 -0
  33. evalrun-0.4.0.dist-info/top_level.txt +4 -0
  34. framework/__init__.py +70 -0
  35. framework/core/__init__.py +17 -0
  36. framework/core/adapters.py +118 -0
  37. framework/core/contracts.py +88 -0
  38. framework/core/suite.py +44 -0
  39. framework/evaluation/__init__.py +22 -0
  40. framework/evaluation/base.py +29 -0
  41. framework/evaluation/dimensions.py +7 -0
  42. framework/evaluation/engine.py +110 -0
  43. framework/evaluation/evaluators/__init__.py +7 -0
  44. framework/evaluation/evaluators/adaptability.py +27 -0
  45. framework/evaluation/evaluators/base_llm.py +104 -0
  46. framework/evaluation/evaluators/constraint.py +27 -0
  47. framework/evaluation/evaluators/information_accuracy.py +41 -0
  48. framework/evaluation/evaluators/personalization.py +27 -0
  49. framework/evaluation/evaluators/planning.py +27 -0
  50. framework/evaluation/evaluators/support.py +81 -0
  51. framework/evaluation/prompts/__init__.py +11 -0
  52. framework/evaluation/prompts/adaptability.py +57 -0
  53. framework/evaluation/prompts/base.py +52 -0
  54. framework/evaluation/prompts/constraint.py +41 -0
  55. framework/evaluation/prompts/information_accuracy.py +79 -0
  56. framework/evaluation/prompts/personalization.py +57 -0
  57. framework/evaluation/prompts/planning.py +61 -0
  58. framework/evaluation/runner.py +363 -0
  59. framework/evaluation/testing.py +25 -0
  60. framework/exceptions.py +49 -0
  61. framework/llms/__init__.py +8 -0
  62. framework/llms/base.py +37 -0
  63. framework/llms/factory.py +38 -0
  64. framework/llms/gemini.py +85 -0
  65. framework/llms/mock.py +25 -0
  66. framework/llms/openai.py +94 -0
  67. framework/llms/openai_compatible.py +139 -0
  68. framework/mcp/__init__.py +20 -0
  69. framework/mcp/client.py +62 -0
  70. framework/mcp/constraints.py +125 -0
  71. framework/mcp/revision_summary.py +122 -0
  72. framework/mcp/server.py +49 -0
  73. framework/memory/__init__.py +3 -0
  74. framework/memory/base.py +17 -0
  75. framework/models.py +83 -0
  76. framework/parser.py +45 -0
  77. framework/parsers/__init__.py +12 -0
  78. framework/parsers/frontmatter.py +28 -0
  79. framework/parsers/mapper.py +66 -0
  80. framework/parsers/markdown.py +122 -0
  81. framework/parsers/transformers.py +112 -0
  82. framework/profiles/__init__.py +28 -0
  83. framework/profiles/registry.py +104 -0
  84. framework/profiles/support.py +27 -0
  85. framework/profiles/travel.py +78 -0
  86. framework/regression/__init__.py +17 -0
  87. framework/regression/comparator.py +273 -0
  88. framework/regression/loader.py +145 -0
  89. framework/sdk.py +151 -0
  90. framework/utils.py +41 -0
  91. framework/verification/__init__.py +13 -0
  92. framework/verification/base.py +32 -0
  93. framework/verification/extractor.py +105 -0
  94. framework/verification/local.py +122 -0
  95. framework/verification/models.py +112 -0
  96. framework/verification/pipeline.py +36 -0
  97. framework/verification/prompts.py +24 -0
  98. framework/verification/utils.py +54 -0
  99. ui/__init__.py +1 -0
  100. ui/server.py +255 -0
@@ -0,0 +1,94 @@
1
+ """OpenAI client wrapper implementation."""
2
+
3
+ from typing import List, Optional
4
+ from framework.llms.base import BaseLLM, LLMResponse, Message
5
+
6
+
7
+ class OpenAILLM(BaseLLM):
8
+ """OpenAI API client wrapper."""
9
+
10
+ def __init__(
11
+ self,
12
+ model_name: str = "gpt-5.6-terra",
13
+ api_key: Optional[str] = None,
14
+ base_url: str = "https://api.openai.com/v1",
15
+ timeout: float = 60.0,
16
+ max_retries: int = 2,
17
+ ):
18
+ """Initializes the OpenAILLM.
19
+
20
+ Validates that the 'openai' library is installed immediately.
21
+
22
+ Args:
23
+ model_name: The target model ID.
24
+ api_key: The OpenAI API key. Reads from environment if None.
25
+ base_url: The API base URL to connect to. Defaults to OpenAI API.
26
+ timeout: The request timeout in seconds. Defaults to 60.0.
27
+ max_retries: The maximum number of retries for request failures. Defaults to 2.
28
+
29
+ Raises:
30
+ ImportError: If the 'openai' library is not installed.
31
+ """
32
+ try:
33
+ # pyrefly: ignore [missing-import]
34
+ import openai
35
+ except ImportError:
36
+ raise ImportError(
37
+ "The 'openai' library is required to use OpenAILLM. "
38
+ "Please install it using 'pip install openai'."
39
+ )
40
+
41
+ self._client = openai.OpenAI(
42
+ api_key=api_key,
43
+ base_url=base_url,
44
+ timeout=timeout,
45
+ max_retries=max_retries,
46
+ )
47
+ self.model_name = model_name
48
+ self.max_retries = max_retries
49
+ # GPT-5 family Chat Completions models use the newer
50
+ # `max_completion_tokens` parameter. NVIDIA's OpenAI-compatible NIM
51
+ # endpoint continues to expect `max_tokens`.
52
+ self._token_parameter = (
53
+ "max_completion_tokens"
54
+ if base_url.rstrip("/") == "https://api.openai.com/v1" and model_name.startswith("gpt-5")
55
+ else "max_tokens"
56
+ )
57
+
58
+ def generate(self, messages: List[Message]) -> LLMResponse:
59
+ # Compatibility layer: prepend system content to user content
60
+ # since some NIM endpoints (e.g. MiniMax) do not support the system role.
61
+ formatted_messages = []
62
+ system_content = ""
63
+ for msg in messages:
64
+ if msg.role == "system":
65
+ system_content += msg.content + "\n"
66
+ else:
67
+ role = msg.role
68
+ content = msg.content
69
+ if system_content:
70
+ content = system_content + "\n" + content
71
+ system_content = ""
72
+ formatted_messages.append({"role": role, "content": content})
73
+
74
+ if system_content and not formatted_messages:
75
+ formatted_messages.append({"role": "user", "content": system_content})
76
+
77
+ request_kwargs = {
78
+ "model": self.model_name,
79
+ "messages": formatted_messages,
80
+ self._token_parameter: 4096,
81
+ }
82
+ for attempt in range(self.max_retries + 1):
83
+ response = self._client.chat.completions.create(
84
+ **request_kwargs,
85
+ )
86
+
87
+ if not response.choices:
88
+ raise ValueError(f"API returned empty choices. Full response: {response}")
89
+
90
+ response_text = response.choices[0].message.content or ""
91
+ if response_text.strip():
92
+ return LLMResponse(text=response_text)
93
+
94
+ return LLMResponse(text=response_text)
@@ -0,0 +1,139 @@
1
+ import os
2
+ import time
3
+ from typing import Any, Dict, List, Optional
4
+ from openai import OpenAI
5
+
6
+ from framework.llms.base import BaseLLM, LLMResponse, Message
7
+
8
+
9
+ class OpenAICompatibleLLM(BaseLLM):
10
+ """Universal OpenAI-compatible completion client adapter.
11
+
12
+ Supports cloud hosted APIs (OpenAI, NVIDIA NIM, Gemini OpenAI-compat) and
13
+ local user-hosted servers (vLLM, LM Studio, Ollama) via a single base_url.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ model_name: str,
19
+ api_key: Optional[str] = None,
20
+ base_url: Optional[str] = None,
21
+ timeout: float = 180.0,
22
+ max_retries: int = 3,
23
+ retry_delay: float = 1.0,
24
+ extra_headers: Optional[Dict[str, str]] = None,
25
+ provider: str = "openai_compatible",
26
+ ):
27
+ """Initializes the OpenAICompatibleLLM client.
28
+
29
+ Args:
30
+ model_name: Model identifier (e.g. 'gpt-5.6-terra', 'qwen2.5-72b-instruct', 'llama-3.3-70b').
31
+ api_key: API key. Defaults to OPENAI_API_KEY environment variable or 'EMPTY' for local endpoints.
32
+ base_url: Base endpoint URL (e.g. 'http://localhost:8000/v1', 'https://integrate.api.nvidia.com/v1').
33
+ timeout: Request timeout in seconds.
34
+ max_retries: Maximum retry attempts on transient errors.
35
+ retry_delay: Base delay between retries in seconds.
36
+ extra_headers: Optional custom HTTP headers.
37
+ provider: Human-readable provider label.
38
+ """
39
+ self.model_name = model_name
40
+ self.provider = provider
41
+ self.base_url = base_url or "https://api.openai.com/v1"
42
+ self.timeout = timeout
43
+ self.max_retries = max_retries
44
+ self.retry_delay = retry_delay
45
+ self.extra_headers = extra_headers
46
+
47
+ resolved_api_key = api_key or os.environ.get("OPENAI_API_KEY") or "EMPTY"
48
+ self.client = OpenAI(
49
+ api_key=resolved_api_key,
50
+ base_url=self.base_url,
51
+ timeout=self.timeout,
52
+ default_headers=self.extra_headers,
53
+ )
54
+
55
+ self.request_count: int = 0
56
+ self.retries_attempted: int = 0
57
+ self.last_error: Optional[str] = None
58
+ self.last_token_usage: Optional[Dict[str, int]] = None
59
+
60
+ def _is_transient_error(self, e: Exception) -> bool:
61
+ """Determines whether an exception is a transient network/server error suitable for retries."""
62
+ if isinstance(e, (TimeoutError, ConnectionError)):
63
+ return True
64
+ status_code = getattr(e, "status_code", None)
65
+ if status_code is not None:
66
+ if status_code in (429, 500, 502, 503, 504):
67
+ return True
68
+ if 400 <= status_code < 500 and status_code != 429:
69
+ return False
70
+ err_str = str(e).lower()
71
+ if any(k in err_str for k in ("timeout", "connection", "rate limit", "500", "502", "503", "504", "deadline exceeded")):
72
+ return True
73
+ return False
74
+
75
+ def generate(self, messages: List[Message]) -> LLMResponse:
76
+ """Generates a text response from chat messages with retry logic.
77
+
78
+ Args:
79
+ messages: List of Message instances.
80
+
81
+ Returns:
82
+ An LLMResponse containing the text output and execution metadata.
83
+ """
84
+ formatted_messages = [{"role": msg.role, "content": msg.content} for msg in messages]
85
+
86
+ response = None
87
+ last_exception = None
88
+ retries_this_call = 0
89
+
90
+ for attempt in range(1 + self.max_retries):
91
+ try:
92
+ response = self.client.chat.completions.create(
93
+ model=self.model_name,
94
+ messages=formatted_messages,
95
+ )
96
+ break
97
+ except Exception as e:
98
+ last_exception = e
99
+ self.last_error = str(e)
100
+ if attempt < self.max_retries and self._is_transient_error(e):
101
+ retries_this_call += 1
102
+ self.retries_attempted += 1
103
+ time.sleep(self.retry_delay * (2 ** attempt))
104
+ else:
105
+ raise last_exception
106
+
107
+ self.request_count += 1
108
+ self.last_error = None
109
+
110
+ if hasattr(response, "usage") and response.usage:
111
+ self.last_token_usage = {
112
+ "prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
113
+ "completion_tokens": getattr(response.usage, "completion_tokens", 0),
114
+ "total_tokens": getattr(response.usage, "total_tokens", 0),
115
+ }
116
+ else:
117
+ self.last_token_usage = None
118
+
119
+ if not getattr(response, "choices", None):
120
+ return LLMResponse(
121
+ text="",
122
+ metadata={
123
+ "error": "Empty choices array in model response",
124
+ "provider": self.provider,
125
+ "model_name": self.model_name,
126
+ "retries_attempted": retries_this_call,
127
+ },
128
+ )
129
+
130
+ content = response.choices[0].message.content or ""
131
+ return LLMResponse(
132
+ text=content,
133
+ metadata={
134
+ "provider": self.provider,
135
+ "model_name": self.model_name,
136
+ "token_usage": self.last_token_usage,
137
+ "retries_attempted": retries_this_call,
138
+ },
139
+ )
@@ -0,0 +1,20 @@
1
+ """MCP integrations for deterministic agent validation tools."""
2
+
3
+ from framework.mcp.constraints import (
4
+ calculate_savings,
5
+ get_locked_constraints,
6
+ validate_revision,
7
+ )
8
+ from framework.mcp.client import TravelValidationMCPClient
9
+
10
+ __all__ = [
11
+ "calculate_savings",
12
+ "get_locked_constraints",
13
+ "validate_revision",
14
+ "TravelValidationMCPClient",
15
+ ]
16
+ """MCP-backed deterministic validation components."""
17
+
18
+ from .revision_summary import RevisionSummary, parse_revision_summary
19
+
20
+ __all__ = ["RevisionSummary", "parse_revision_summary"]
@@ -0,0 +1,62 @@
1
+ """Async client for the local travel-validation MCP server."""
2
+
3
+ import os
4
+ import sys
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from mcp import ClientSession, StdioServerParameters
8
+ from mcp.client.stdio import stdio_client
9
+
10
+
11
+ class TravelValidationMCPClient:
12
+ """Connects to the local travel constraint server over stdio.
13
+
14
+ The client creates a short-lived session per request. This is appropriate for
15
+ the current local validation workflow and keeps the agent integration
16
+ explicit; long-lived connection management is unnecessary until deployment.
17
+ """
18
+
19
+ def __init__(self, python_executable: Optional[str] = None):
20
+ self.python_executable = python_executable or sys.executable
21
+
22
+ def _server_parameters(self) -> StdioServerParameters:
23
+ return StdioServerParameters(
24
+ command=self.python_executable,
25
+ args=["-m", "framework.mcp.server"],
26
+ env=dict(os.environ),
27
+ )
28
+
29
+ async def list_tools(self) -> List[str]:
30
+ """Returns the names of tools exposed by the validation server."""
31
+ async with stdio_client(self._server_parameters()) as (read, write):
32
+ async with ClientSession(read, write) as session:
33
+ await session.initialize()
34
+ result = await session.list_tools()
35
+ return [tool.name for tool in result.tools]
36
+
37
+ async def call_tool(
38
+ self, name: str, arguments: Dict[str, Any]
39
+ ) -> Dict[str, Any]:
40
+ """Calls a tool and returns its structured JSON response.
41
+
42
+ Raises:
43
+ RuntimeError: If the MCP server reports an error or returns an
44
+ unexpected non-dictionary payload.
45
+ """
46
+ async with stdio_client(self._server_parameters()) as (read, write):
47
+ async with ClientSession(read, write) as session:
48
+ await session.initialize()
49
+ result = await session.call_tool(name, arguments)
50
+
51
+ if result.isError:
52
+ raise RuntimeError(f"MCP tool '{name}' failed: {result.content}")
53
+ if not isinstance(result.structuredContent, dict):
54
+ raise RuntimeError(
55
+ f"MCP tool '{name}' returned an unexpected payload: {result.content}"
56
+ )
57
+ payload = dict(result.structuredContent)
58
+ # FastMCP wraps plain dictionary tool returns under ``result``. Keep the
59
+ # application-facing client independent from that transport detail.
60
+ if set(payload) == {"result"} and isinstance(payload["result"], dict):
61
+ return dict(payload["result"])
62
+ return payload
@@ -0,0 +1,125 @@
1
+ """Deterministic constraint checks used by the travel validation MCP server."""
2
+
3
+ from copy import deepcopy
4
+ from typing import Any, Dict, List
5
+
6
+
7
+ MID_TRIP_REPLANNING_SCENARIO = "travel-mid-trip-replanning"
8
+
9
+ _SCENARIO_CONSTRAINTS: Dict[str, Dict[str, Any]] = {
10
+ MID_TRIP_REPLANNING_SCENARIO: {
11
+ "scenario_id": MID_TRIP_REPLANNING_SCENARIO,
12
+ "immutable_booking_ids": ["kyoto-hostel", "narita-return-flight"],
13
+ "locked_bookings": [
14
+ {
15
+ "booking_id": "kyoto-hostel",
16
+ "description": "Kyoto hostel stay on Days 15–18",
17
+ "start_day": 15,
18
+ "end_day": 18,
19
+ "reason": "Pre-paid and non-refundable",
20
+ },
21
+ {
22
+ "booking_id": "narita-return-flight",
23
+ "description": "Return flight from Narita on Day 28",
24
+ "start_day": 28,
25
+ "end_day": 28,
26
+ "reason": "Non-changeable",
27
+ },
28
+ ],
29
+ "required_savings_inr": 20000.0,
30
+ "revision_start_day": 13,
31
+ }
32
+ }
33
+
34
+
35
+ def get_locked_constraints(scenario_id: str) -> Dict[str, Any]:
36
+ """Returns immutable bookings and numeric targets for a supported scenario."""
37
+ try:
38
+ return deepcopy(_SCENARIO_CONSTRAINTS[scenario_id])
39
+ except KeyError as error:
40
+ raise ValueError(f"Unsupported scenario: '{scenario_id}'") from error
41
+
42
+
43
+ def validate_revision(
44
+ scenario_id: str,
45
+ booking_actions: List[Dict[str, str]],
46
+ changed_days: List[int],
47
+ ) -> Dict[str, Any]:
48
+ """Checks whether a proposed revision changes an immutable booking.
49
+
50
+ ``booking_actions`` must include one entry for each locked booking with an
51
+ action of ``preserve``, ``move``, ``cancel``, or ``modify``. The function
52
+ intentionally validates only explicit, deterministic assertions; the LLM
53
+ remains responsible for itinerary quality and travel judgment.
54
+ """
55
+ constraints = get_locked_constraints(scenario_id)
56
+ immutable_ids = constraints["immutable_booking_ids"]
57
+ actions_by_booking = {
58
+ action.get("booking_id"): action.get("action")
59
+ for action in booking_actions
60
+ if action.get("booking_id")
61
+ }
62
+ violations = []
63
+
64
+ for booking_id in immutable_ids:
65
+ action = actions_by_booking.get(booking_id)
66
+ if action is None:
67
+ violations.append(
68
+ f"No preservation assertion was supplied for locked booking '{booking_id}'."
69
+ )
70
+ elif action != "preserve":
71
+ violations.append(
72
+ f"Locked booking '{booking_id}' must be preserved, not '{action}'."
73
+ )
74
+
75
+ invalid_days = [
76
+ day
77
+ for day in changed_days
78
+ if not isinstance(day, int) or day < constraints["revision_start_day"]
79
+ ]
80
+ if invalid_days:
81
+ violations.append(
82
+ "A replanning revision cannot modify days before Day "
83
+ f"{constraints['revision_start_day']}: {invalid_days}."
84
+ )
85
+
86
+ return {
87
+ "valid": not violations,
88
+ "violations": violations,
89
+ "immutable_booking_ids": immutable_ids,
90
+ "changed_days": changed_days,
91
+ }
92
+
93
+
94
+ def calculate_savings(
95
+ scenario_id: str, savings_items: List[Dict[str, Any]]
96
+ ) -> Dict[str, Any]:
97
+ """Totals explicit savings items against the scenario's required reduction."""
98
+ constraints = get_locked_constraints(scenario_id)
99
+ normalized_items = []
100
+ invalid_items = []
101
+
102
+ for item in savings_items:
103
+ label = str(item.get("label", "")).strip()
104
+ try:
105
+ amount_inr = float(item["amount_inr"])
106
+ except (KeyError, TypeError, ValueError):
107
+ invalid_items.append(item)
108
+ continue
109
+ if not label or amount_inr < 0:
110
+ invalid_items.append(item)
111
+ continue
112
+ normalized_items.append({"label": label, "amount_inr": amount_inr})
113
+
114
+ total_savings_inr = sum(item["amount_inr"] for item in normalized_items)
115
+ target_savings_inr = constraints["required_savings_inr"]
116
+ remaining_gap_inr = max(target_savings_inr - total_savings_inr, 0.0)
117
+
118
+ return {
119
+ "target_savings_inr": target_savings_inr,
120
+ "total_savings_inr": total_savings_inr,
121
+ "remaining_gap_inr": remaining_gap_inr,
122
+ "target_met": not invalid_items and remaining_gap_inr == 0.0,
123
+ "accepted_items": normalized_items,
124
+ "invalid_items": invalid_items,
125
+ }
@@ -0,0 +1,122 @@
1
+ """Structured, LLM-produced claims for deterministic revision validation.
2
+
3
+ The travel itinerary remains free-form text for the traveler. Before a
4
+ replanning revision is accepted, the model must also provide this small JSON
5
+ summary of the claims that a deterministic tool can verify.
6
+ """
7
+
8
+ from dataclasses import asdict, dataclass
9
+ from typing import Any, Dict, List
10
+
11
+ from framework.utils import parse_json_markdown
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class BookingAction:
16
+ """A declared treatment of one booking in a proposed itinerary revision."""
17
+
18
+ booking_id: str
19
+ action: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SavingsItem:
24
+ """One concrete cost-saving substitution claimed by the proposed revision."""
25
+
26
+ label: str
27
+ amount_inr: float
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RevisionSummary:
32
+ """The complete deterministic-validation contract for a replanning draft."""
33
+
34
+ scenario_id: str
35
+ changed_days: List[int]
36
+ booking_actions: List[BookingAction]
37
+ savings_items: List[SavingsItem]
38
+
39
+ def to_tool_arguments(self) -> Dict[str, Any]:
40
+ """Returns JSON-compatible arguments for the validation MCP tools."""
41
+ return {
42
+ "scenario_id": self.scenario_id,
43
+ "booking_actions": [asdict(action) for action in self.booking_actions],
44
+ "changed_days": self.changed_days,
45
+ "savings_items": [asdict(item) for item in self.savings_items],
46
+ }
47
+
48
+
49
+ REVISION_SUMMARY_JSON_TEMPLATE = """{
50
+ "scenario_id": "travel-mid-trip-replanning",
51
+ "changed_days": [13, 19, 22],
52
+ "booking_actions": [
53
+ {"booking_id": "kyoto-hostel", "action": "preserve"},
54
+ {"booking_id": "narita-return-flight", "action": "preserve"}
55
+ ],
56
+ "savings_items": [
57
+ {"label": "Kyoto-to-Tokyo night bus instead of Shinkansen", "amount_inr": 12000}
58
+ ]
59
+ }"""
60
+
61
+
62
+ def parse_revision_summary(text: str) -> RevisionSummary:
63
+ """Parses and validates the JSON contract emitted by an LLM.
64
+
65
+ This parser intentionally rejects incomplete or ambiguous declarations. A
66
+ model must explicitly state every field before its proposal can be passed
67
+ to the deterministic MCP tools.
68
+ """
69
+ payload = parse_json_markdown(text)
70
+ if not isinstance(payload, dict):
71
+ raise ValueError("Revision summary must be a JSON object.")
72
+
73
+ scenario_id = payload.get("scenario_id")
74
+ if not isinstance(scenario_id, str) or not scenario_id.strip():
75
+ raise ValueError("Revision summary requires a non-empty scenario_id.")
76
+
77
+ changed_days = payload.get("changed_days")
78
+ if not isinstance(changed_days, list) or not all(
79
+ isinstance(day, int) and not isinstance(day, bool) for day in changed_days
80
+ ):
81
+ raise ValueError("changed_days must be a list of integer day numbers.")
82
+
83
+ raw_booking_actions = payload.get("booking_actions")
84
+ if not isinstance(raw_booking_actions, list):
85
+ raise ValueError("booking_actions must be a list.")
86
+ booking_actions = []
87
+ for item in raw_booking_actions:
88
+ if not isinstance(item, dict):
89
+ raise ValueError("Each booking action must be an object.")
90
+ booking_id = item.get("booking_id")
91
+ action = item.get("action")
92
+ if not isinstance(booking_id, str) or not booking_id.strip():
93
+ raise ValueError("Each booking action requires a non-empty booking_id.")
94
+ if action not in {"preserve", "move", "cancel", "modify"}:
95
+ raise ValueError("Each booking action must be preserve, move, cancel, or modify.")
96
+ booking_actions.append(BookingAction(booking_id=booking_id, action=action))
97
+
98
+ raw_savings_items = payload.get("savings_items")
99
+ if not isinstance(raw_savings_items, list):
100
+ raise ValueError("savings_items must be a list.")
101
+ savings_items = []
102
+ for item in raw_savings_items:
103
+ if not isinstance(item, dict):
104
+ raise ValueError("Each savings item must be an object.")
105
+ label = item.get("label")
106
+ amount_inr = item.get("amount_inr")
107
+ if not isinstance(label, str) or not label.strip():
108
+ raise ValueError("Each savings item requires a non-empty label.")
109
+ if (
110
+ not isinstance(amount_inr, (int, float))
111
+ or isinstance(amount_inr, bool)
112
+ or amount_inr < 0
113
+ ):
114
+ raise ValueError("Each savings amount_inr must be a non-negative number.")
115
+ savings_items.append(SavingsItem(label=label, amount_inr=float(amount_inr)))
116
+
117
+ return RevisionSummary(
118
+ scenario_id=scenario_id,
119
+ changed_days=changed_days,
120
+ booking_actions=booking_actions,
121
+ savings_items=savings_items,
122
+ )
@@ -0,0 +1,49 @@
1
+ """Local stdio MCP server for travel-planning constraint validation."""
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from framework.mcp.constraints import (
8
+ calculate_savings as calculate_savings_check,
9
+ get_locked_constraints as get_locked_constraints_check,
10
+ validate_revision as validate_revision_check,
11
+ )
12
+
13
+
14
+ mcp = FastMCP(
15
+ "Travel Constraint Validation",
16
+ instructions=(
17
+ "Use these tools to verify explicit locked-booking constraints and "
18
+ "cost-saving arithmetic for travel itinerary revisions."
19
+ ),
20
+ json_response=True,
21
+ )
22
+
23
+
24
+ @mcp.tool()
25
+ def get_locked_constraints(scenario_id: str) -> Dict[str, Any]:
26
+ """Get immutable bookings and savings targets for a benchmark scenario."""
27
+ return get_locked_constraints_check(scenario_id)
28
+
29
+
30
+ @mcp.tool()
31
+ def validate_revision(
32
+ scenario_id: str,
33
+ booking_actions: List[Dict[str, str]],
34
+ changed_days: List[int],
35
+ ) -> Dict[str, Any]:
36
+ """Check whether a revision preserves every locked booking."""
37
+ return validate_revision_check(scenario_id, booking_actions, changed_days)
38
+
39
+
40
+ @mcp.tool()
41
+ def calculate_savings(
42
+ scenario_id: str, savings_items: List[Dict[str, Any]]
43
+ ) -> Dict[str, Any]:
44
+ """Calculate explicit INR savings against a scenario's required reduction."""
45
+ return calculate_savings_check(scenario_id, savings_items)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ mcp.run()
@@ -0,0 +1,3 @@
1
+ """Abstract session memory framework interfaces."""
2
+
3
+ from framework.memory.base import BaseSessionMemory
@@ -0,0 +1,17 @@
1
+ """Abstract base class for session memory."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class BaseSessionMemory(ABC):
7
+ """Abstract base class defining the interface for agent session memory."""
8
+
9
+ @abstractmethod
10
+ def to_yaml(self) -> str:
11
+ """Serializes memory state to structured YAML string for LLM injection."""
12
+ pass
13
+
14
+ @abstractmethod
15
+ def to_json(self) -> str:
16
+ """Serializes memory state to JSON string representation."""
17
+ pass