Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,7 @@
- 상태 폴더 권한 진단(2026-10-31, ADR-0080, 구현·로컬 검증 완료): Unix mode의 group/other 비트 거절은 유지하고, root·runs·개별 Run 폴더 오류를 `state_directory_permissions`/exit64로 전달한다. CLI run/resume/usage/recover·REPL에서 문제 경로·현재 mode·`chmod go-rwx -- PATH` 안내를 표시하며 자동 chmod·재귀 수정은 없다. 명시적 로컬 진단만 경로를 escape해 표시하고 일반 Debug·오류 Display·journal·모델·progress에서는 가린다. 설계 root0775/runs0755와 별도 run0770·symlink·space·개행/ESC·비UTF8 경로를 검증했다. 사용자가 직접0700으로 고치면 권한 검사 다음 단계로 진행하며 fixture DB는 생성되지 않는다. 전체 Rust 회귀778개(4 ignored)·Clippy·fmt·protocol·공개 문서·배포 workflow 검사를 통과했다. 소유자는 xgen CLI이며 외부 도구 변경은 없다.
- 상태 폴더 진단 변경 추적: [PR #137](https://github.com/PlateerLab/xgen-cli/pull/137), 코드 `7edf540`, [전체 CI](https://github.com/PlateerLab/xgen-cli/actions/runs/41077456833). 실제 외부 모델·다른 OS의 terminal 화면 실측은 하지 않았다.
- 상태 폴더 진단의 [Linux PR 검사](https://github.com/PlateerLab/xgen-cli/actions/runs/41077575969)가 통과했다. 플랫폼 전체 결과는 위 전체 CI run을 참조한다.

- HTTP 조회 확장 요청(2026-10-31, 사용자 승인): 범용 기능으로 선택 unsupported 파라미터 단위 제외 → query 배열(form/explode, Swagger multi) → operation별 호스트 지정 POST 조회 → Run별 bearer env 주입 순서다. 1번은 단독 PR로 먼저 제공한다. 승인 있는 쓰기는 대회 후 별도 설계다. 상세 정본은 [통합 TODO](docs/development/tool-integration-todo.md#http-조회-확장-요청-2026-10-31). 외부 커머스 API는 통합 사례로만 쓰고 설계 기준에 넣지 않는다. 생성기·endpoint·인증·비교기는 외부 통합 담당자의 책임이며 실제 실행 가능 과업 수는 미검증이다.
- 선택 HTTP 파라미터(ADR-0081, 구현·로컬 검증): override 병합 뒤 지원 밖 선택 항목만 제외하고 name/location/reason을 contract digest와 모델 describe에 포함한다. required/path·보안/응답 오류는 계속 operation을 거절한다. 기존 host의 closed parameter 검사로 제외 입력은 요청 전에 거절된다. assets/calendar 설계와 shipment/sensor 실제 loopback import→describe→GET·악의적 제외 입력 0회 요청을 확인했다. 기존 HTTP 실패 테스트의 즉시 종료 기대를 ADR-0052의 실패 관찰 후 보고 계약에 맞췄으며 엔진 실패 의미는 바꾸지 않았다. 자체 과제 평가는 [draft #138](https://github.com/PlateerLab/xgen-cli/pull/138)에 보존했고 모델 인증 대기는 계속된다.
- 선택 파라미터 검증 결과: Rust778개(4 ignored), system Python venv+graph-tool-call0.47.0+컴파일 CLI의 전체139개 검사(7 skipped), HTTP 관련18개 전부 통과했다. Clippy·fmt·protocol·문서·workflow 검사도 통과했다. UV standalone Python의 pidfd_open 부재는 기존 평가 process-cleanup 도구의 환경 제약으로 분리했고 system Python에서 전체 검증했다.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Local-first general-purpose agent CLI and harness.

> 현재 상태: 프로토콜 v0.1 검증, dependency DAG와 Receipt-gated 재개 frontier를 갖춘 model-free WorkGraph, embedded SQLite schema 8 store, effect 실행·복구 조정기, 결정론적 Capability Registry·Router, I/O 없는 Permission Broker, secret-free invocation material 복구, Direct Executor와 Core-owned Execution Receipt 검증 경계, 장기 Run용 VerifiedRunIndex를 실행할 수 있습니다. Provider-neutral bounded AgentLoop는 비신뢰 `PlanProposal`을 검증해 다중 Step DAG와 reconstructable input sidecar를 원자 commit하고 재시작 뒤 한 frontier action씩 이어갑니다. Model call은 provider 호출 전에 보수적인 possible-send slot을 journal에 예약하며 accepted/rejected/Unknown을 재시작 뒤 복원하고, 불확정 호출을 자동 재시도하지 않습니다. 별도 `xgen-provider-openai` leaf adapter는 OpenAI-compatible Chat Completions의 단일 요청과 strict structured proposal을 이 lifecycle에 연결합니다. 내부 WorkGraph는 planned `ReadOnly`/`Idempotent`/`NonIdempotent`, Core Receipt v2의 bounded Artifact commitment와 event-anchored typed `ToolOutputRecord`를 지원하며, generation-checked `PlanningContext v3`는 Step의 plan journal 순서와 passed Receipt에 결합된 exact ToolOutput의 관찰 순서를 보존해 다음 model turn에 전달합니다. NonIdempotent durable output은 결과만 보존하고 Started 뒤 불확정 effect의 no-replay 의미는 유지합니다. Public `xgen run/resume`은 기존 exact `--allow-file` mode를 보존하면서, 명시적 `--allow-dir` mode에서 네 read Capability와 `write-atomic`·`apply-patch`로 workspace를 탐색·수정합니다. Host가 등록한 executable은 별도 승인 뒤 shell 없이 bounded cwd·argv·환경·timeout·출력 제한으로 실행하며 결과를 durable하게 보존하고 불확정 실행을 자동 반복하지 않습니다. Dynamic path/query/write/process material은 Run 전용 embedded `materials.sqlite3`에 digest-bound recipe로 저장되어 approval pause 뒤 다른 process에서도 복원됩니다. 모델 프로필은 일반 설정과 OS 보안 credential 저장소를 분리하고 catalog와 실제 strict-JSON inference를 모두 검증합니다. Bare `xgen`는 이 경계 위에서 Thinking 표시와 검증된 최종 응답을 제공하고 model/read/write/execute를 각각 묻는 가벼운 REPL을 시작합니다. Network tool과 XGEN compatibility adapter는 후속 범위입니다.
> 현재 상태: 프로토콜 v0.1 검증, dependency DAG와 Receipt-gated 재개 frontier를 갖춘 model-free WorkGraph, embedded SQLite schema 8 store, effect 실행·복구 조정기, 결정론적 Capability Registry·Router, I/O 없는 Permission Broker, secret-free invocation material 복구, Direct Executor와 Core-owned Execution Receipt 검증 경계, 장기 Run용 VerifiedRunIndex를 실행할 수 있습니다. Provider-neutral bounded AgentLoop는 비신뢰 `PlanProposal`을 검증해 다중 Step DAG와 reconstructable input sidecar를 원자 commit하고 재시작 뒤 한 frontier action씩 이어갑니다. Model call은 provider 호출 전에 보수적인 possible-send slot을 journal에 예약하며 accepted/rejected/Unknown을 재시작 뒤 복원하고, 불확정 호출을 자동 재시도하지 않습니다. 별도 `xgen-provider-openai` leaf adapter는 OpenAI-compatible Chat Completions의 단일 요청과 strict structured proposal을 이 lifecycle에 연결합니다. 내부 WorkGraph는 planned `ReadOnly`/`Idempotent`/`NonIdempotent`, Core Receipt v2의 bounded Artifact commitment와 event-anchored typed `ToolOutputRecord`를 지원하며, generation-checked `PlanningContext v3`는 Step의 plan journal 순서와 passed Receipt에 결합된 exact ToolOutput의 관찰 순서를 보존해 다음 model turn에 전달합니다. NonIdempotent durable output은 결과만 보존하고 Started 뒤 불확정 effect의 no-replay 의미는 유지합니다. Public `xgen run/resume`은 기존 exact `--allow-file` mode를 보존하면서, 명시적 `--allow-dir` mode에서 네 read Capability와 `write-atomic`·`apply-patch`로 workspace를 탐색·수정합니다. Host가 등록한 executable은 별도 승인 뒤 shell 없이 bounded cwd·argv·환경·timeout·출력 제한으로 실행하며 결과를 durable하게 보존하고 불확정 실행을 자동 반복하지 않습니다. Dynamic path/query/write/process material은 Run 전용 embedded `materials.sqlite3`에 digest-bound recipe로 저장되어 approval pause 뒤 다른 process에서도 복원됩니다. 모델 프로필은 일반 설정과 OS 보안 credential 저장소를 분리하고 catalog와 실제 strict-JSON inference를 모두 검증합니다. Bare `xgen`는 이 경계 위에서 Thinking 표시와 검증된 최종 응답을 제공하고 model/read/write/execute를 각각 묻는 가벼운 REPL을 시작합니다. 로컬 JSON OpenAPI 명세로 연결한 제한된 HTTP GET 조회는 지원합니다. 지원 범위는 [HTTP 조회 문서](docs/development/http-read-tools.md)를 따르며, HTTP 쓰기·범용 MCP 클라이언트·XGEN compatibility adapter는 후속 범위입니다.

## 설치와 첫 모델 연결

Expand Down
105 changes: 83 additions & 22 deletions crates/xgen-cli/src/tool_graph_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,11 @@ def dispatch(request):
raise WorkerError("unknown_operation")


def dereference(value, document, stack=(), depth=0):
def dereference(value, document, stack=(), depth=0, *, recursive=True):
if depth > 48:
raise WorkerError("http_schema_depth_limit")
if isinstance(value, list):
return [dereference(item, document, stack, depth+1) for item in value]
return [dereference(item, document, stack, depth+1) for item in value] if recursive else value
if not isinstance(value, dict):
return value
if "$ref" in value:
Expand All @@ -368,11 +368,11 @@ def dereference(value, document, stack=(), depth=0):
target = target[part.replace("~1", "/").replace("~0", "~")]
except (KeyError, TypeError):
raise WorkerError("http_schema_reference_missing") from None
resolved = dereference(target, document, (*stack,reference), depth+1)
resolved = dereference(target, document, (*stack,reference), depth+1, recursive=recursive)
if len(value) != 1:
raise WorkerError("http_schema_ref_siblings_unsupported")
return resolved
return {key:dereference(item, document, stack, depth+1) for key,item in value.items()}
return {key:dereference(item, document, stack, depth+1) for key,item in value.items()} if recursive else value


def check_http_schema(value):
Expand Down Expand Up @@ -449,6 +449,19 @@ def describe_view(envelope, tool, http, offset):
"bearer_required": contract["bearer_required"],
"response_schema_digests": {status: digest(schema) for status, schema in list(contract["responses"].items())[:32] if len(status) <= 3},
"response_schema_count": len(contract["responses"]), "response_schemas_complete": False})
if http["supported"] and http["contract"].get("excluded_parameters"):
excluded = http["contract"]["excluded_parameters"]
excluded_rows = []
for parameter in excluded[:32]:
row = {"location": parameter["location"], "name": preview(parameter["name"], 1024),
"reason": parameter["reason"]}
if len(canonical(excluded_rows + [row])) > 8 * 1024:
break
excluded_rows.append(row)
http_view.update({"excluded_parameters": excluded_rows, "excluded_parameter_count": len(excluded),
"excluded_parameters_digest": digest(excluded),
"excluded_parameters_complete": len(excluded_rows) == len(excluded)
and all(not row["name"]["omitted"] for row in excluded_rows)})
result = {"ok": True, **summarize(envelope), "view_version": 2,
"contract_kind": "discovery_view", "effect_class": "unclassified",
"full_tool_digest": digest(tool), "complete": False,
Expand All @@ -469,6 +482,31 @@ def describe_view(envelope, tool, http, offset):
return result


def http_read_parameter(parameter, source):
location, name = parameter.get("in"), parameter.get("name")
if location not in ("path", "query"):
raise WorkerError("http_parameter_location_unsupported")
if "content" in parameter:
if "schema" in parameter or not isinstance(parameter["content"], dict) or len(parameter["content"]) != 1:
raise WorkerError("http_original_contract_invalid")
raise WorkerError("http_parameter_serialization_unsupported")
schema = parameter.get("schema")
if "schema" not in parameter and source.get("swagger") == "2.0" and "type" in parameter:
schema = {key: value for key, value in parameter.items() if key in (
"type", "enum", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
"minLength", "maxLength", "pattern", "format", "default", "multipleOf")}
if not isinstance(schema, (dict, bool)):
raise WorkerError("http_original_contract_invalid")
schema = dereference(schema, source)
if not isinstance(schema, dict) or schema.get("type") not in ("string", "integer", "number", "boolean"):
raise WorkerError("http_parameter_type_unsupported")
if parameter.get("style", "simple" if location == "path" else "form") != ("simple" if location == "path" else "form") or parameter.get("allowReserved", False):
raise WorkerError("http_parameter_serialization_unsupported")
check_http_schema(schema)
return {"location": location, "name": name,
"required": bool(parameter.get("required")) or location == "path", "schema": schema}


def http_read_contract(artifact, tool):
# The normalized graph is discovery data. Invoke only against retained source schemas.
metadata = tool.get("metadata", {})
Expand All @@ -479,28 +517,51 @@ def http_read_contract(artifact, tool):
method, path = metadata.get("method"), metadata.get("path")
if method != "get":
raise WorkerError("http_method_not_read_approved")
item = dereference(source["paths"][path], source)
item = dereference(source["paths"][path], source, recursive=False)
operation = item[method]
if operation.get("requestBody") or any(p.get("in") == "body" for p in operation.get("parameters", [])):
if not isinstance(operation, dict):
raise WorkerError("http_original_contract_invalid")
parameter_groups = [item.get("parameters", []), operation.get("parameters", [])]
if any(not isinstance(group, list) or any(not isinstance(p, dict) for p in group)
for group in parameter_groups):
raise WorkerError("http_original_contract_invalid")
if operation.get("requestBody") or any(p.get("in") == "body" for p in parameter_groups[1]):
raise WorkerError("http_get_body_unsupported")
# Operation overrides are allowed across groups; duplicates within one group are invalid.
merged = {}
for parameter in [*item.get("parameters", []), *operation.get("parameters", [])]:
parameter = dereference(parameter, source)
location, name = parameter.get("in"), parameter.get("name")
if location not in ("path", "query") or not isinstance(name, str) or not name:
raise WorkerError("http_parameter_location_unsupported")
schema = parameter.get("schema")
if schema is None and source.get("swagger") == "2.0":
schema = {key:value for key,value in parameter.items() if key in ("type","enum","minimum","maximum","exclusiveMinimum","exclusiveMaximum","minLength","maxLength","pattern","format","default","multipleOf")}
if not isinstance(schema, dict) or schema.get("type") not in ("string","integer","number","boolean"):
raise WorkerError("http_parameter_type_unsupported")
if parameter.get("style", "simple" if location == "path" else "form") != ("simple" if location == "path" else "form") or parameter.get("allowReserved",False) or parameter.get("content"):
raise WorkerError("http_parameter_serialization_unsupported")
merged[(location,name)] = {"location":location,"name":name,"required":bool(parameter.get("required")) or location=="path","schema":dereference(schema,source)}
for group in parameter_groups:
seen = set()
for parameter in group:
parameter = dereference(parameter, source, recursive=False)
if not isinstance(parameter, dict):
raise WorkerError("http_original_contract_invalid")
location, name = parameter.get("in"), parameter.get("name")
if location not in ("path", "query", "header", "cookie") or not isinstance(name, str) or not name:
raise WorkerError("http_parameter_location_unsupported")
key = (location, name)
if key in seen or ("required" in parameter and type(parameter["required"]) is not bool):
raise WorkerError("http_original_contract_invalid")
seen.add(key)
merged[key] = parameter
included, excluded = [], []
for (location, name), parameter in merged.items():
try:
included.append(http_read_parameter(parameter, source))
except WorkerError as error:
# Only known unsupported parameter shapes can be omitted. Structural,
# unknown parameter identity, response and security failures still close the operation.
if parameter.get("required") or location == "path" or str(error) not in (
"http_parameter_location_unsupported", "http_parameter_type_unsupported",
"http_parameter_serialization_unsupported", "http_schema_dialect_unsupported",
"http_schema_reference_unsupported", "http_schema_ref_siblings_unsupported",
"http_schema_depth_limit"):
raise
excluded.append({"location": location, "name": name, "reason": str(error)})
responses = {}
for status,response in operation.get("responses", {}).items():
if not str(status).isdigit() or not 200 <= int(status) <= 299:
continue
response = dereference(response, source)
if source.get("swagger") == "2.0":
produces = operation.get("produces", source.get("produces", []))
if produces and "application/json" not in produces:
Expand All @@ -522,11 +583,11 @@ def http_read_contract(artifact, tool):
scheme = dereference(schemes.get(name,{}),source)
if scheme.get("type") != "http" or scheme.get("scheme", "").lower() != "bearer" or scopes:
raise WorkerError("http_security_unsupported")
for parameter in merged.values():
check_http_schema(parameter["schema"])
for schema in responses.values():
check_http_schema(schema)
contract = {"format_version":1,"method":"GET","path":path,"base_path":source.get("basePath", ""),"parameters":list(merged.values()),"responses":responses,"bearer_required":bool(security) and not any(not requirement for requirement in security),"source_digest":digest(source)}
contract = {"format_version":1,"method":"GET","path":path,"base_path":source.get("basePath", ""),"parameters":included,"responses":responses,"bearer_required":bool(security) and not any(not requirement for requirement in security),"source_digest":digest(source)}
if excluded:
contract["excluded_parameters"] = excluded
if len(canonical(contract)) > 8 * 1024 * 1024:
raise WorkerError("http_contract_size_limit")
return {"supported":True,"contract_digest":digest(contract),"contract":contract}
Expand Down
Loading