api_mem module)본 문서는
research-pi_mem_text_summary_adaptation.md의 Phase 1~4 를 코드 단위까지 분해한 implementation plan. 한 명이 처음부터 따라가도 막힘 없이 BinFill ep0 까지 도달 가능하도록 파일/함수/JSON schema/CLI flag 까지 명시.원칙 (이전 작업의 educated lesson): - 기존
gemini/,qwenvl/경로는 무수정. fork 가 아니라 새 모듈로. - 모든 신규 코드는 git tracked,tmp/는 임시 산출물 한정. - try/except 로 에러 숨기지 않음. 첫 1 ep 검증 단계까지는 raise + log. - login GPU 안 씀 (API call only). SLURM 도 일단 안 씀 (90 min 안에 1 task 끝나는 빈도면).
~/env/api_keys.txt 라 명시하지만 실제는 ~/envs/api_keys.txt (복수형 dir).Name: value (콜론 구분, lowercase key 가능).
Gemini: <redacted>
WANDB: <redacted>_load_gemini_key() 함수는 = 가 아니라 : 으로 split, key name 은 case-insensitive gemini match.def _load_gemini_key(self):
p = Path.home() / "envs" / "api_keys.txt" # NOTE: "envs" (plural)
for line in p.read_text().splitlines():
if ":" not in line:
continue
name, value = line.split(":", 1)
if name.strip().lower() == "gemini":
os.environ["GEMINI_API_KEY"] = value.strip()
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
return
raise RuntimeError("Gemini key not found in ~/envs/api_keys.txt")
api_mem 모듈 신설, 1 day)examples/robomme/subgoal_prediction/api_mem/
├── __init__.py # 빈 파일
├── api.py # ApiMemModel (Gemini provider 1개부터)
├── prompts/
│ ├── __init__.py # prompt_dict_grounded_with_memory 빌드
│ ├── base.py # MEMORY_RULES + OUTPUT_FORMAT_WITH_MEMORY + helper
│ └── <TASK>.py × 16 # 기존 gemini/prompts/<TASK>.py 의 subgoals/example/notes 를
│ # base.py 의 새 template 로 다시 fmt
└── README.md # 모듈 사용법
prompts/base.py — system prompt 설계기존 gemini/prompts/base.py 의 SYSTEM_PROMPT 와 분리해서 새 template 정의.
# api_mem/prompts/base.py
GROUNDED_COORD_INFO = """\
For subgoals that include a target object, emit a 2-D point in the PIXEL
coordinate frame normalized to 0..1000, e.g. "pick up the red cube at <y, x>".
Use the LAST input image to ground the point. If the object is not visible in
the last image, copy the coordinate from the previous subgoal."""
MEMORY_RULES = """\
You also maintain a compressed natural-language MEMORY of the events that
have happened in this episode so far.
Hard rules for the memory:
1. Only describe COMPLETED subtasks. Never describe attempts, failures, or
"is approaching X" states.
2. If the previous subtask in the input has not yet visibly completed in the
current image, COPY the previous memory string UNCHANGED, and re-issue the
same previous subgoal (not a new one).
3. Compress aggressively. Replace enumerations with counts:
"I picked up a red cube and a blue cube" -> on completion of the third pick,
write "I have picked 3 cubes". Drop attributes once they no longer affect
the next decision.
4. Never invent events you cannot verify from the current image.
5. After the FINAL subtask in the example sequence is completed, set memory to
"task complete" and re-issue the final subgoal."""
OUTPUT_FORMAT_WITH_MEMORY = """\
Output STRICT JSON only, no prose, no markdown fences:
```json
{
"scratchpad": "<= 30 words of reasoning, ignored downstream>",
"memory_summary": "<= 60 tokens, follows the memory rules above>",
"subgoal": "<one of the listed subgoals, with grounded <y, x>>"
}
```"""
SYSTEM_PROMPT_WITH_MEMORY = """\
You are the HIGH-LEVEL controller of a robotic arm. At each call you decide
ONE subgoal for a separate low-level controller to execute, and you maintain
a compressed MEMORY of progress so far.
# Possible subgoals (this task)
{subgoals}
# Example episode (this task)
{example}
# Per-task notes
{notes}
# Memory rules
""" + MEMORY_RULES + """
# Coordinate grounding
""" + GROUNDED_COORD_INFO + """
# Output format
""" + OUTPUT_FORMAT_WITH_MEMORY
USER_PROMPT_FIRST_CALL = """\
Task goal: {task_goal}
Previous memory: (empty)
Previous subgoal: (none)
Current image follows.
{demo_video_marker}"""
USER_PROMPT_STEP = """\
Task goal: {task_goal}
Previous memory: {mt}
Previous subgoal: {last_subgoal}
Current image follows."""
DEMO_VIDEO_MARKER = "Demo video for initial setup follows after the image. " \
"Use it to learn the desired sequence; do NOT re-watch on later calls."
{notes} 는 기존 gemini/prompts/<TASK>.py 의 notes 변수 그대로 재사용.
{subgoals} / {example} 는 grounded 변형 (subgoals_grounded, example_grounded) 사용.
prompts/<TASK>.py × 16기존 gemini/prompts/<TASK>.py 의 변수 4 개 (subgoals_grounded, example_grounded, notes,
optionally keep_period_hints) 만 import 해서 새 SYSTEM_PROMPT 에 fmt:
# api_mem/prompts/BinFill.py
from subgoal_prediction.gemini.prompts.BinFill import (
subgoals_grounded, example_grounded, notes
)
from subgoal_prediction.api_mem.prompts.base import SYSTEM_PROMPT_WITH_MEMORY
BinFill_SYSTEM_PROMPT_API_MEM = SYSTEM_PROMPT_WITH_MEMORY.format(
subgoals=subgoals_grounded,
example=example_grounded,
notes=notes,
)
→ per-task 변경 시 gemini/prompts/<TASK>.py 만 고치면 양쪽 다 반영. fork 하지 않음.
api.py — ApiMemModel# api_mem/api.py
import os, re, json, time
from pathlib import Path
from typing import Optional
import imageio.v3 as iio
import google.generativeai as genai
from subgoal_prediction.api_mem.prompts import prompt_dict_with_memory
class ApiMemModel:
"""Zero-shot API VLM that emits {memory_summary, subgoal, scratchpad}.
Per-call lifecycle:
start_new_episode(save_dir, demo_video, task_goal, task_id)
get_subgoal(current_image, last_subgoal_was_complete) -> subgoal_str
end_episode()
"""
def __init__(self, provider: str = "gemini", model_name: Optional[str] = None,
image_size: tuple = (256, 256)):
self.provider = provider
self.image_size = image_size
if provider == "gemini":
self._load_gemini_key()
self.model_name = model_name or "gemini-2.5-pro"
else:
raise NotImplementedError(f"Provider {provider} not yet supported")
def _load_gemini_key(self):
key_file = Path.home() / "env" / "api_keys.txt"
for line in key_file.read_text().splitlines():
if line.startswith("GEMINI_API_KEY="):
os.environ["GEMINI_API_KEY"] = line.split("=", 1)[1].strip()
genai.configure(api_key=os.environ["GEMINI_API_KEY"])
return
raise RuntimeError("GEMINI_API_KEY missing in ~/env/api_keys.txt")
def start_new_episode(self, save_dir: str, demo_video, task_goal: str, task_id: str):
self.save_dir = save_dir
os.makedirs(save_dir, exist_ok=True)
ep_name = os.path.basename(save_dir)
self.log_path = os.path.join(os.path.dirname(save_dir), f"{ep_name}_ApiMem_log.jsonl")
self.task_goal = task_goal
self.task_id = task_id
self.mt = "" # language memory
self.last_subgoal = None
self.last_subgoal_completed = True
self.uploaded_files = [] # for cleanup
self.call_idx = 0
# Build system instruction (per-task)
sys_prompt = prompt_dict_with_memory[task_id]
self.model = genai.GenerativeModel(
model_name=self.model_name,
system_instruction=sys_prompt,
)
# Cache demo video upload (one-shot, reused on first call)
if demo_video is not None and len(demo_video) > 0:
video_path = os.path.join(save_dir, "demo_video.mp4")
iio.imwrite(video_path, demo_video, plugin="pyav", codec="h264", fps=30)
uf = genai.upload_file(path=video_path)
self.uploaded_files.append(uf)
while uf.state.name == "PROCESSING":
time.sleep(0.2); uf = genai.get_file(uf.name)
self.demo_video_file = uf
else:
self.demo_video_file = None
def get_subgoal(self, current_image, last_subgoal_completed: bool) -> str:
# 1. Save current image to disk + upload
img_path = os.path.join(self.save_dir, f"step_{self.call_idx}_image.png")
iio.imwrite(img_path, current_image)
img_uf = genai.upload_file(path=img_path)
self.uploaded_files.append(img_uf)
# 2. Build user prompt
if self.call_idx == 0:
user_text = USER_PROMPT_FIRST_CALL.format(
task_goal=self.task_goal,
demo_video_marker=DEMO_VIDEO_MARKER if self.demo_video_file else ""
)
parts = [user_text, img_uf]
if self.demo_video_file:
parts.append(self.demo_video_file)
else:
user_text = USER_PROMPT_STEP.format(
task_goal=self.task_goal,
mt=self.mt or "(empty)",
last_subgoal=self.last_subgoal or "(none)"
)
parts = [user_text, img_uf]
# 3. Call API. retry once on parse fail.
for attempt in range(2):
response = self.model.generate_content(parts)
response_text = response.text
parsed = self._parse_json(response_text)
if parsed is not None:
break
print(f"[api_mem] parse fail (attempt {attempt+1}), response was:\n{response_text}")
else:
raise RuntimeError("API output failed to parse twice in a row.")
# 4. Update mt (rule 2: only update if last subgoal completed)
new_mt = parsed["memory_summary"]
new_subgoal = parsed["subgoal"]
# Log raw
self._log_call(user_text, response_text, parsed)
# Trust the model's rule-2 compliance (system prompt enforces).
self.mt = new_mt
self.last_subgoal = new_subgoal
self.call_idx += 1
return new_subgoal
def _parse_json(self, text: str) -> Optional[dict]:
# strip markdown fences
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
try:
obj = json.loads(text)
except json.JSONDecodeError:
return None
for k in ("memory_summary", "subgoal"):
if k not in obj:
return None
return obj
def _log_call(self, user_text, response_text, parsed):
with open(self.log_path, "a") as f:
json.dump({
"call_idx": self.call_idx,
"user_text": user_text,
"response_text": response_text,
"parsed": parsed,
"prev_mt": self.mt,
"prev_subgoal": self.last_subgoal,
}, f)
f.write("\n")
def end_episode(self):
for uf in self.uploaded_files:
try: genai.delete_file(uf.name)
except Exception: pass
self.uploaded_files = []
subgoal_predictor.py — ApiMemSubgoalPredictor# 추가 import
from subgoal_prediction.api_mem.api import ApiMemModel
class ApiMemSubgoalPredictor(SubgoalPredictorBase):
def setup_api(self) -> None:
self.api = ApiMemModel(
provider=self.args.api_mem_provider,
model_name=self.args.api_mem_model_name,
)
print(f"[robomme] ApiMem ({self.args.api_mem_provider}) agent setup")
def start_episode(self, epstate, env_runner):
super().start_episode(epstate, env_runner)
self.episode_dir = os.path.join(self.save_dir, self.env_name, f"ep{self.episode_id}")
# demo_video = epstate.image_buffer[:-1] if task has demo, else None
demo = epstate.image_buffer[:-1] if self.env_name in TASK_WITH_VIDEO_DEMO else None
self.api.start_new_episode(
save_dir=self.episode_dir,
demo_video=demo,
task_goal=self.task_goal,
task_id=self.env_name,
)
self._call_period = 48 # match Gemini path
self._step_count = 0
def step(self, epstate):
self._step_count += 1
def get_subgoal(self, count, current_subgoal, last_subgoal):
if count > 0 and count % self._call_period != 0:
return current_subgoal, False
# Get latest image from epstate via env_runner (already in api state)
# NOTE: we need image via predictor api — pass from caller.
# Actually current_image comes via step() in epstate.image_buffer[-1]
last_img = self.env_runner.last_image # add accessor or store in step()
subgoal = self.api.get_subgoal(last_img, last_subgoal_completed=True)
return subgoal, False
def end_episode(self, epstate, success_flag):
self.api.end_episode()
subgoal_predictor.py build switchdef build_subgoal_predictor(args, save_dir):
if args.use_gemini: return GeminiSubgoalPredictor(args, save_dir)
if args.use_qwenvl: return QwenVLSubgoalPredictor(args, save_dir)
if args.use_memer: return MemERSubgoalPredictor(args, save_dir)
if args.use_api_mem: return ApiMemSubgoalPredictor(args, save_dir) # NEW
if args.use_oracle: return OracleSubgoalPredictor(args, save_dir)
return NullSubgoalPredictor(args, save_dir)
examples/robomme/eval.py 의 args)기존 args struct 에 추가 (eval.py 무수정 원칙이지만 flag 추가는 OK, monkey-patch 와 다른 범주):
--args.use-api-mem : bool, default False
--args.api-mem-provider : str, default "gemini"
--args.api-mem-model-name : str, default None (provider default 사용)
--args.api-mem-call-period : int, default 48
만약 eval.py 무수정을 강제로 지키려면 → scripts/run_eval_with_mplib_patch.py 에서
args 객체에 monkey-patch 형태로 attribute 주입. 하지만 cleaner 한 건 eval.py 에 attr 추가
(MemER 추가 때도 추가했음). 결정: 추가.
examples/robomme/subgoal_prediction/api_mem/{__init__.py, api.py, prompts/} 전체examples/robomme/subgoal_predictor.py (+50 줄, ApiMemSubgoalPredictor)examples/robomme/eval.py (+4 줄, 새 flag)examples/robomme/env_runner.py (필요 시 last_image accessor)cd ~/repos/Robotics/robomme_policy_learning
python3 -c "from examples.robomme.subgoal_prediction.api_mem.api import ApiMemModel; \
m = ApiMemModel(provider='gemini'); print('ok')"
scripts/run_eval_with_mplib_patch.py (closed-loop sweep 와 같은 wrapper) 에
--args.use-api-mem flag 만 다르게 줘서 1 ep 동작.scripts/run_api_mem_one_episode.sh:
bash
bash scripts/run_api_mem_one_episode.sh BinFill 0
내부에서:
bash
CUDA_VISIBLE_DEVICES=0 \
"$REPO_ROOT/.venv/bin/python" scripts/serve_policy.py ... &
CLIENT_GPU=1 \
"$ROBOMME/bin/python" scripts/run_eval_with_mplib_patch.py \
--args.use-api-mem --args.api-mem-provider=gemini \
--args.policy_name=symbolic-grounded-subgoal --args.model_ckpt_id=79999 \
--args.save_dir=runs/api_mem_smoke/BinFill \
--args.only_tasks=BinFill --args.only-ep0runs/api_mem_smoke/BinFill/.../BinFill/ep0_ApiMem_log.jsonl → call 별로:parsed.memory_summary 가 시간순으로 monotonic 하게 늘어나는가? (shrink 하면 rule 위반)memory_summary 가 unchanged 인가? (rule 2)<y, x> 의 sanity (단순 inspection — 실제 gripper 가 그 픽셀 근처로 가는가)videos/BinFill_ep0_*.mp4 가 success 로 끝나는가? (= MemER baseline 64% 보다 좋아야 의미 있음)| 증상 | 1차 의심 | 대응 |
|---|---|---|
| JSON 깨짐 | system prompt 의 strict-mode 약함 | retry once 후 system prompt 끝에 Output strict JSON or you fail. 추가 |
| mt 가 매 call 마다 새로 씀 | rule 2 무시 | system prompt 에 "If unchanged, copy verbatim" 강조 + previous memory: 를 user msg 가장 위에 |
| count compression 안 됨 | example 부족 | per-task example_grounded 를 count 누적 형태로 다시 작성 |
| 좌표 OOD | API 의 grounding 가 256×256 에서 약함 | 입력 image 를 1024×1024 로 upscale (norm 0..1000 system prompt 와 매칭) |
| latency > 5 s/call | gemini-2.5-pro 의 video 첨부 cost | demo video 첨부 첫 1 회만, 이후 image only — 이미 설계에 반영 |
| ID | Predictor | Memory | 용도 |
|---|---|---|---|
| A | MemERSubgoalPredictor (LoRA) |
keyframe FIFO | baseline (이미 sweep 결과 있음) |
| B | ApiMemSubgoalPredictor provider=gemini, mt 비활성 |
없음 | API 모델 자체 capability |
| C | ApiMemSubgoalPredictor provider=gemini, mt 활성 |
compressed text | 본 제안 |
| D | ApiMemSubgoalPredictor provider=gemini, naive concat |
concat last N subgoals | distribution-shift 검증 |
B, D 는 코드 path 동일, system prompt 만 다르게. → --args.api-mem-prompt-variant flag 로 switch:
- mem (default, 본 제안)
- no_mem (memory rules 섹션 제거, JSON 에 memory_summary 빼고 subgoal 만)
- naive_concat (memory_summary 자리에 직전 N=10 호출의 subgoal 을 concat 으로 user msg 에 박음, model 은 subgoal 만 출력)
BinFill, PickXtimes, SwingXtimes, ButtonUnmask, ButtonUnmaskSwap, PatternLock.
선정 근거: counting/sequencing tier — research-pi_mem_text_summary_adaptation.md Phase 3 의 가설 검증 대상.
scripts/launch_api_mem_ablation.sh:
bash
conda activate robomme
for variant in mem no_mem naive_concat; do
sbmr 5 "bash scripts/run_api_mem_tier.sh $variant" \
--gres=gpu:1 -c 14 --mem=200GB --qos=core-extra \
-J "apimem-$variant"
done
# variant=A 는 별도 (LoRA, 이미 결과 있음 → 재실행 X)runs/api_mem_ablation/<variant>/<TASK>/<predictor>/... 별 progress.jsonscripts/aggregate_api_mem_ablation.py (기존 aggregate_closed_loop.py 변형) 로 4×6 표.scripts/launch_api_mem_full.sh 로 16 task × 50 ep sweep.launch_closed_loop_sweep.sh 와 동일 구조, predictor flag 만 다름.scripts/render_with_memory.py 확장 → scripts/render_with_text_memory.py:mt[t] (60-token wrap)runs/api_mem_full/<TASK>/.../videos_with_text_memory/<TASK>_ep<N>_with_mt.mp4claude/<YYMMDD>/exp-api_mem_full_sweep.md (이번 plan 외 별도 doc — 실측 수치 기록).with_mt 비디오.| 위험 | 영향 | 사전 대응 |
|---|---|---|
| Gemini API rate-limit (RPM) | sweep 중단 | 1 task 당 sleep 200 ms 삽입, sbm retry 5회. 또는 Anthropic provider 추가. |
| 비용 폭주 | budget overrun | Phase 2 에서 1 ep 당 token cost 측정 후 16 task × 50 ep × N call 곱해서 사전 estimate. budget cap (e.g. $50) 넘으면 stop. |
| pi0.5 가 새 subtask 표현 못 받아드림 | sr 폭락 | system prompt rule "한 글자도 변형 말고 listed subgoals 중 하나만" 강조, 첫 1 ep 검수에서 subtask string 의 distribution match 확인. |
eval.py 변경에 의한 sweep 재현성 손상 |
기존 결과와 비교 불가 | eval.py 의 변경은 flag 추가 한정 (default False), 기존 path 동작 무영향. |
| Demo video 의 첫 호출 cost 가 큼 | latency 5+ s | upload 한 번만, genai.upload_file 결과 episode 내 reuse — 이미 설계 반영. |
| API JSON parse fail | 호출 실패 | retry 1 회. 두 번 다 실패 시 last_subgoal 유지하고 mt unchanged → graceful degradation. |
| Phase | 작업 | 소요 | Deliverable |
|---|---|---|---|
| 0 | 키 확인 | 30 min | boolean check |
| 1 | api_mem/ 모듈 + predictor + flag |
1 day | import 통과 |
| 2 | BinFill ep0 smoke | 0.5 day | mt log 검수 + ep0 success |
| 3 | counting tier 4-way ablation | 1.5 day | 4×6 표, 가설 ≥ 2 개 검증 |
| 4 | 16 task full + 시각화 | 1 day | full sr 표 + with_mt 비디오 |
총 ≈ 4 day (사람 검수 시간 별도 0.5 day).
call_period = 48 step 의 적정성. PatternLock 같이 빠른 sequence 가 있는 task 에서 너무 느릴 수 있음. Phase 2 BinFill 검수 후 task-별 hint dict 가 필요할 수도.research-pi_mem_text_summary_adaptation.mdexp-closed_loop_full_sweep.md../260428/setup-robomme_b200_cluster.mdexamples/robomme/subgoal_prediction/gemini/api.py (zero-shot API base)examples/robomme/subgoal_prediction/qwenvl/api_memer.py (memory FIFO 비교 대상)examples/robomme/subgoal_predictor.py (predictor 추가 위치)https://www.pi.website/download/Mem.pdf (./tmp/mem.pdf 캐시).