File size: 10,809 Bytes
7043cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0983a18
7043cc6
 
 
d78447a
 
 
 
 
0983a18
d78447a
 
 
7043cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d78447a
7043cc6
 
 
 
 
 
 
 
 
 
aaf289c
 
7043cc6
 
 
 
aaf289c
7043cc6
 
 
 
aaf289c
7043cc6
 
aaf289c
7043cc6
aaf289c
7043cc6
 
 
aaf289c
7043cc6
 
 
d78447a
7043cc6
 
0983a18
7043cc6
 
 
0983a18
7043cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d78447a
7043cc6
 
 
 
 
d78447a
aaf289c
7043cc6
 
aaf289c
7043cc6
aaf289c
 
 
 
7043cc6
 
 
 
 
 
 
 
aaf289c
7043cc6
aaf289c
7043cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aaf289c
 
7043cc6
 
aaf289c
 
 
7043cc6
aaf289c
 
 
7043cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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
"""
inference.py β€” LLM inference loop for AdaptiveWorld environment (v2).

Updated from Round 1 api-debug-env inference.py to:
  - Use AdaptiveAction / AdaptiveObservation v2 types
  - Support all 5 action types: call_api, probe_schema, query_history,
    declare_belief, submit_result
  - System prompt guides the agent to detect drift from evidence
    (NOT from an explicit drift_occurred field β€” that's removed in v2)
  - Extract belief_state from LLM output for belief scoring
  - Track task_reward and belief_accuracy separately across episodes
"""
import os
import json
import re
from typing import Optional

from dotenv import load_dotenv
from openai import OpenAI

from client import AdaptiveWorldEnv
from models import AdaptiveAction, AdaptiveObservation

load_dotenv()

# ── Config ─────────────────────────────────────────────────────────────────────
ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "meta-llama/Llama-3.3-70B-Instruct")
MAX_STEPS = int(os.getenv("MAX_STEPS", "12"))
DIFFICULTY = os.getenv("DIFFICULTY", "easy")

# Use Hugging Face serverless router using local HF token
import huggingface_hub
hf_token = os.getenv("HF_TOKEN") or huggingface_hub.get_token() or "dummy"

client = OpenAI(
    base_url="https://api.groq.com/openai/v1",
    api_key=hf_token
)
env = AdaptiveWorldEnv(base_url=ENV_URL).sync()

# ── System prompt ──────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are an agent completing professional tasks via HTTP APIs.

IMPORTANT: The API world can mutate mid-episode without warning.
- Field names may change (e.g. "qty" β†’ "quantity")
- Endpoints may move (e.g. /book β†’ /v2/book)
- Policies may change (new required fields, new auth schemes)
- Status values may change silently (200 OK but different meaning)

You will NOT be told when a drift occurs. You must detect it from evidence.

AVAILABLE ACTION TYPES:
1. call_api       β€” Make an HTTP request to the mock API
2. probe_schema   β€” GET /openapi.json to inspect current API contract
3. query_history  β€” Review your last N API responses, compare for changes
4. declare_belief β€” State your current understanding of the world (saves for later scoring)
5. submit_result  β€” End the episode. Include your final belief_state about what changed.

STRATEGY:
- Start with probe_schema to understand the initial API contract
- After any unexpected error (422, 404, 401), call query_history first to compare responses
- If responses look the same but downstream fails silently, the MEANING may have changed
- Use declare_belief to record discoveries as you go
- Always submit_result with your final belief_state β€” this is scored separately from task completion*

*Belief accuracy is tracked independently. You can score high on belief even if the task fails.

RESPONSE FORMAT (JSON, always):
{
    "action_type": "call_api",  // or probe_schema, query_history, declare_belief, submit_result
    "method": "POST",           // for call_api
    "url": "/mock_api/orders",  // for call_api
    "headers": {"Content-Type": "application/json"},  // for call_api
    "body": {"quantity": 2, "product_id": 5},         // for call_api
    "query_params": {},         // for call_api GET requests
    "belief_state": {           // for declare_belief or submit_result
        "order_field": "quantity",
        "required_extra": "customer_id"
    },
    "history_steps": 3          // for query_history
}
"""


def build_user_message(obs: AdaptiveObservation, step: int) -> str:
    """Build the per-step user message from the current observation."""
    parts = [
        f"=== Step {step} ===",
        f"Task: {obs.task_description}",
        f"Domain: {obs.domain}",
        f"Progress: {obs.current_step}/{obs.max_steps} steps",
        f"Difficulty level: {obs.difficulty_level}/3",
    ]

    if obs.prior_world_model:
        parts.append(
            f"\\nYour prior world model (from previous episodes in this domain):\\n"
            f"{json.dumps(obs.prior_world_model, indent=2)}\\n"
            "NOTE: This may be stale if the world has changed since last episode."
        )

    if obs.last_status_code > 0:
        parts.append(f"\\nLast API call:")
        parts.append(f"  Status: {obs.last_status_code}")
        parts.append(f"  Response: {obs.last_response_body[:800]}")

    if obs.episode_history:
        parts.append(f"\\nEpisode history (last {len(obs.episode_history)} steps):")
        parts.append(json.dumps(obs.episode_history, indent=2)[:1500])

    parts.append(f"\\nFeedback: {obs.step_feedback}")
    parts.append(
        "\\nRespond with a JSON action object. "
        "If you detect drift, use declare_belief to record it before continuing. "
        "When ready to finish, use submit_result with your final belief_state."
    )
    return "\\n".join(parts)


def parse_action(llm_output: str) -> AdaptiveAction:
    """Parse LLM output to AdaptiveAction. Falls back to safe default on parse error."""
    try:
        # Extract JSON from markdown code blocks or raw output
        match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', llm_output, re.DOTALL)
        if match:
            raw = match.group(1)
        else:
            raw_match = re.search(r'\{.*\}', llm_output, re.DOTALL)
            raw = raw_match.group() if raw_match else "{}"

        data = json.loads(raw)
        return AdaptiveAction(
            action_type=data.get("action_type", "call_api"),
            method=data.get("method", "GET"),
            url=data.get("url", "/mock_api/orders"),
            headers=data.get("headers", {}),
            body=data.get("body", {}),
            query_params=data.get("query_params", {}),
            belief_state=data.get("belief_state"),
            history_steps=int(data.get("history_steps", 3)),
        )
    except Exception as e:
        print(f"[parse_action] Parse error: {e}. Defaulting to probe_schema.")
        return AdaptiveAction(action_type="probe_schema")


def run_episode(
    scenario_id: str = "auto",
    difficulty: str = "easy",
    verbose: bool = True,
) -> dict:
    """
    Run one complete episode.

    Returns:
      dict with task_reward, belief_accuracy, combined_reward,
      steps_taken, drift_detected
    """
    step_result = env.reset(scenario_id=scenario_id, difficulty=difficulty)

    if verbose:
        print(f"\\n{'='*60}")
        print(f"EPISODE START")
        print(f"Task: {step_result.observation.task_description}")
        print(f"Domain: {step_result.observation.domain}")
        print(f"Difficulty: {step_result.observation.difficulty_level}/3")
        print(f"Prior beliefs: {step_result.observation.prior_world_model}")
        print(f"{'='*60}")

    conversation: list = [{"role": "system", "content": SYSTEM_PROMPT}]
    step = 0
    final_reward = 0.0
    task_reward = 0.0
    belief_accuracy = 0.0

    while not step_result.done and step < MAX_STEPS:
        step += 1
        user_msg = build_user_message(step_result.observation, step)
        conversation.append({"role": "user", "content": user_msg})

        # LLM call
        response = client.chat.completions.create(
            model=OPENAI_MODEL,
            messages=conversation,
            temperature=0.2,
            max_tokens=512,
        )
        llm_output = response.choices[0].message.content
        conversation.append({"role": "assistant", "content": llm_output})

        if verbose:
            print(f"\n--- Step {step} ---")
            print(f"LLM: {llm_output[:400]}")

        action = parse_action(llm_output)

        if verbose:
            print(f"Action: {action.action_type} | {action.method} {action.url}")

        step_result = env.step(action)
        final_reward = step_result.reward

        if verbose:
            print(f"Status: {step_result.observation.last_status_code} | Reward: {step_result.reward:.4f}")
            if step_result.observation.step_feedback:
                print(f"Feedback: {step_result.observation.step_feedback}")

        if step_result.done:
            task_reward = step_result.observation.task_reward
            belief_accuracy = step_result.observation.belief_accuracy
            break

    if verbose:
        print(f"\n{'='*60}")
        print(f"EPISODE COMPLETE")
        print(f"Steps taken: {step}")
        print(f"Task reward: {task_reward:.4f}")
        print(f"Belief accuracy: {belief_accuracy:.4f}")
        print(f"Combined reward: {final_reward:.4f}")
        print(f"{'='*60}\n")

    return {
        "task_reward": task_reward,
        "belief_accuracy": belief_accuracy,
        "combined_reward": final_reward,
        "steps_taken": step,
    }


def run_evaluation(
    n_episodes: int = 10,
    scenario_id: str = "auto",
    difficulty: str = "easy",
) -> dict:
    """
    Run N episodes and return aggregate statistics.
    Used for Day 3 reward signal verification.
    """
    results = []
    for i in range(n_episodes):
        print(f"\n=== Episode {i+1}/{n_episodes} ===")
        r = run_episode(scenario_id=scenario_id, difficulty=difficulty, verbose=True)
        results.append(r)

    task_rewards = [r["task_reward"] for r in results]
    belief_accs = [r["belief_accuracy"] for r in results]
    combined = [r["combined_reward"] for r in results]

    summary = {
        "n_episodes": n_episodes,
        "avg_task_reward": sum(task_rewards) / len(task_rewards),
        "avg_belief_accuracy": sum(belief_accs) / len(belief_accs),
        "avg_combined_reward": sum(combined) / len(combined),
        "max_task_reward": max(task_rewards),
        "max_belief_accuracy": max(belief_accs),
    }

    print("\n=== EVALUATION SUMMARY ===")
    for k, v in summary.items():
        print(f"  {k}: {v:.4f}" if isinstance(v, float) else f"  {k}: {v}")

    return summary


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="AdaptiveWorld Inference")
    parser.add_argument("--scenario", default="auto", help="Scenario ID or 'auto'")
    parser.add_argument("--difficulty", default="easy",
                        choices=["easy", "medium", "hard", "expert"])
    parser.add_argument("--episodes", type=int, default=1)
    args = parser.parse_args()

    if args.episodes == 1:
        run_episode(scenario_id=args.scenario, difficulty=args.difficulty)
    else:
        run_evaluation(
            n_episodes=args.episodes,
            scenario_id=args.scenario,
            difficulty=args.difficulty,
        )