회기 연속성과 멀티 케이스 계약을 영속화
This commit is contained in:
parent
be08c0b573
commit
72353ecd82
26 changed files with 2170 additions and 127 deletions
|
|
@ -698,28 +698,29 @@ async def _stream_agy(
|
|||
raise ProviderError("Agy CLI를 찾을 수 없습니다.")
|
||||
model, effort = await _resolve_selection(req, "agy_cli")
|
||||
prompt = _cli_prompt(system_prompt, user_payload)
|
||||
if os.name == "nt" and len(prompt) > 24_000:
|
||||
raise ProviderError(
|
||||
"Agy CLI 프롬프트가 Windows 명령줄 안전 한도(24,000자)를 초과했습니다."
|
||||
)
|
||||
args = [binary, "--model", model, "--sandbox"]
|
||||
if effort:
|
||||
args += ["--effort", effort]
|
||||
args += [
|
||||
"--print-timeout",
|
||||
f"{int(CLI_TIMEOUT_SECONDS)}s",
|
||||
# 긴 deep-loop 축어록을 Windows argv에 싣지 않는다. Agy의 공식 stream-json
|
||||
# 입력 계약은 prompt를 stdin의 단일 user 이벤트로 받으므로 명령줄 길이 한계를
|
||||
# 피하면서 전체 마스킹 근거를 그대로 보존한다.
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--print",
|
||||
prompt,
|
||||
]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=str(_cli_runtime_cwd()),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_cli_subprocess_env(),
|
||||
)
|
||||
assert proc.stdin is not None
|
||||
assert proc.stdout is not None
|
||||
assert proc.stderr is not None
|
||||
stderr_task = asyncio.create_task(proc.stderr.read())
|
||||
|
|
@ -730,6 +731,30 @@ async def _stream_agy(
|
|||
cached_input_tokens = 0
|
||||
result_status = ""
|
||||
try:
|
||||
# 공식 protocol: 한 줄에 한 user event. 마지막 turn 뒤 stdin을 닫아도 CLI는
|
||||
# terminal result를 내보낸 뒤 종료한다. stdin 거절은 child와 stderr를 정리한 뒤
|
||||
# provider 오류로 승격해 프로세스를 남기지 않는다.
|
||||
try:
|
||||
proc.stdin.write(
|
||||
(
|
||||
json.dumps(
|
||||
{"event": "user", "message": {"content": prompt}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
)
|
||||
await proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError) as exc:
|
||||
raise ProviderError("Agy CLI가 stdin 평가 입력을 수락하지 않았습니다.") from exc
|
||||
finally:
|
||||
if not proc.stdin.is_closing():
|
||||
proc.stdin.close()
|
||||
try:
|
||||
await proc.stdin.wait_closed()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# 이미 종료된 CLI가 close 직후 EOF를 끊어도 finally가 child를 회수한다.
|
||||
pass
|
||||
async with asyncio.timeout(CLI_TIMEOUT_SECONDS):
|
||||
while True:
|
||||
raw = await proc.stdout.readline()
|
||||
|
|
|
|||
|
|
@ -19,8 +19,30 @@ class _FakeStreamReader:
|
|||
return self.body
|
||||
|
||||
|
||||
class _FakeStreamWriter:
|
||||
def __init__(self):
|
||||
self.writes: list[bytes] = []
|
||||
self.closed = False
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self.writes.append(data)
|
||||
|
||||
async def drain(self) -> None:
|
||||
return None
|
||||
|
||||
def is_closing(self) -> bool:
|
||||
return self.closed
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeAgyProcess:
|
||||
def __init__(self, events: list[dict]):
|
||||
self.stdin = _FakeStreamWriter()
|
||||
self.stdout = _FakeStreamReader(
|
||||
[(json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8") for event in events]
|
||||
)
|
||||
|
|
@ -102,6 +124,7 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
|
|||
},
|
||||
]
|
||||
}
|
||||
long_payload = "x" * 24_001
|
||||
with (
|
||||
patch.object(provider_registry, "_binary", return_value="codex.exe"),
|
||||
patch.object(
|
||||
|
|
@ -321,6 +344,7 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
|
|||
captured.append(args)
|
||||
return process
|
||||
|
||||
long_payload = "x" * 24_001
|
||||
with (
|
||||
patch.object(provider_registry, "_binary", return_value="agy"),
|
||||
patch.object(
|
||||
|
|
@ -342,7 +366,7 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
|
|||
result = await provider_registry.generate_with_provider(
|
||||
request,
|
||||
system_prompt="system",
|
||||
user_payload="hello",
|
||||
user_payload=long_payload,
|
||||
)
|
||||
|
||||
self.assertEqual(result.text, "OK")
|
||||
|
|
@ -350,10 +374,16 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(result.tokens_out, 2)
|
||||
self.assertEqual(result.cost_usd, 0.00001515)
|
||||
args = captured[0]
|
||||
print_index = args.index("--print")
|
||||
self.assertEqual(print_index, len(args) - 2)
|
||||
self.assertIn("[시스템 지침]", args[-1])
|
||||
self.assertIn("--input-format", args)
|
||||
self.assertEqual(args[args.index("--input-format") + 1], "stream-json")
|
||||
self.assertEqual(args[args.index("--output-format") + 1], "stream-json")
|
||||
self.assertNotIn("--print", args)
|
||||
self.assertTrue(all(long_payload not in str(arg) for arg in args))
|
||||
self.assertTrue(process.stdin.closed)
|
||||
sent = json.loads(b"".join(process.stdin.writes).decode("utf-8"))
|
||||
self.assertEqual(sent["event"], "user")
|
||||
self.assertIn("[시스템 지침]", sent["message"]["content"])
|
||||
self.assertIn(long_payload, sent["message"]["content"])
|
||||
|
||||
async def test_agy_stream_forwards_live_deltas_without_repeating_final_response(self):
|
||||
capabilities = provider_registry.EngineCapabilitiesResponse(
|
||||
|
|
@ -451,9 +481,52 @@ class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(events[-1].result.tokens_out, 2)
|
||||
self.assertEqual(events[-1].result.cost_usd, 0.00001515)
|
||||
args = captured[0]
|
||||
self.assertIn("--input-format", args)
|
||||
self.assertEqual(args[args.index("--input-format") + 1], "stream-json")
|
||||
self.assertIn("--output-format", args)
|
||||
self.assertEqual(args[args.index("--output-format") + 1], "stream-json")
|
||||
self.assertEqual(args.index("--print"), len(args) - 2)
|
||||
self.assertNotIn("--print", args)
|
||||
|
||||
async def test_agy_stdin_rejection_reaps_child_process(self):
|
||||
request = GenerateRequest(
|
||||
provider="agy_cli",
|
||||
model="gemini-3.6-flash-high",
|
||||
reasoning_effort="high",
|
||||
messages=[EngineMessage(role="user", content="hello")],
|
||||
)
|
||||
process = _FakeAgyProcess([])
|
||||
|
||||
async def broken_drain() -> None:
|
||||
raise BrokenPipeError()
|
||||
|
||||
process.stdin.drain = broken_drain # type: ignore[method-assign]
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
return process
|
||||
|
||||
with (
|
||||
patch.object(provider_registry, "_binary", return_value="agy.exe"),
|
||||
patch.object(
|
||||
provider_registry,
|
||||
"_resolve_selection",
|
||||
AsyncMock(return_value=("gemini-3.6-flash-high", "high")),
|
||||
),
|
||||
patch.object(
|
||||
provider_registry.asyncio,
|
||||
"create_subprocess_exec",
|
||||
fake_create_subprocess_exec,
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(provider_registry.ProviderError, "stdin 평가 입력"):
|
||||
async for _event in provider_registry._stream_agy(
|
||||
request,
|
||||
system_prompt="system",
|
||||
user_payload="hello",
|
||||
):
|
||||
pass
|
||||
|
||||
self.assertTrue(process.stdin.closed)
|
||||
self.assertEqual(process.returncode, -9)
|
||||
|
||||
async def test_generation_rejects_model_effort_not_returned_by_provider(self):
|
||||
capabilities = provider_registry.EngineCapabilitiesResponse(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue