update roadmap and implement runtime proto changes
This commit is contained in:
parent
a0eea23af2
commit
636bf264de
21 changed files with 575 additions and 161 deletions
|
|
@ -49,8 +49,8 @@ RAG, context 구성/압축, web search, MCP 정책, tool policy, output validati
|
|||
- 요청 내용, 현재 브랜치, 변경 파일, 관련 코드 경로를 보고 가장 관련 있는 활성 Phase와 Milestone 문서를 같은 세션에서 1회 읽는다.
|
||||
- 활성 Phase 또는 Milestone 밖의 작업이면 이 문서의 Phase 흐름을 확인하고 사용자에게 진행 또는 전환 여부를 확인한다.
|
||||
- 이 문서는 로드맵 생성/갱신, Phase 전환, Phase 추가/수정, 전체 구조 변경 요청이 있을 때만 읽는다.
|
||||
- 상세 작업과 완료 기준은 각 Milestone 문서의 `필수 기능`, `완료 기준`으로 관리한다.
|
||||
- 모든 필수 작업과 완료 기준이 충족된 Milestone은 먼저 `[검토중]`으로 두고, 사용자 완료 확인과 archive 승인을 받은 뒤 `[완료]`로 전환한다.
|
||||
- 상세 작업은 각 Milestone 문서의 `기능`으로 관리한다. 검증이 필요한 기능만 같은 Task 안에 `검증:`으로 통합한다.
|
||||
- 모든 기능 Task와 Task 안에 명시된 검증이 충족된 Milestone은 먼저 `[검토중]`으로 두고, 사용자 완료 확인과 archive 승인을 받은 뒤 `[완료]`로 전환한다.
|
||||
- 완료된 Phase는 archive Phase 문서 경로로 이동하고, 하위 Milestone도 같은 archive Phase scaffold 아래에 둔다.
|
||||
- 진행중 Phase 안에서 완료된 Milestone은 활성 Phase 문서에 짧은 링크를 남기고, 상세 문서는 해당 archive Phase 하위 `milestones/` 경로로 이동한다.
|
||||
- archive `PHASE.md`는 Phase 자체가 완료 또는 폐기될 때만 만들며, 진행중 Phase의 완료 Milestone만 archive된 경우 archive Phase 디렉터리에 `milestones/`만 있을 수 있다.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Edge에 OpenAI API 방식으로 접속한 외부 agent/client가 Edge-Node 소
|
|||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
[완료]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
@ -35,54 +35,54 @@ Edge에 OpenAI API 방식으로 접속한 외부 agent/client가 Edge-Node 소
|
|||
|
||||
### Epic: [e2e-serving-core] E2E Serving Core
|
||||
|
||||
- [ ] [edge-node-ollama-path] Edge OpenAI-compatible API 요청이 Edge-Node 소켓을 통해 Node Ollama adapter로 전달되는 E2E 경로가 검증되어 있다.
|
||||
- [ ] [manual-node-select] Node 선택은 자동 라우팅이 아니라 `openai.node` 또는 단일 Node fallback 같은 수동 설정 기준으로 동작한다.
|
||||
- [ ] [manual-model-select] model 선택은 요청 `model` 또는 `openai.target` 고정값을 통해 수동으로 검증할 수 있다.
|
||||
- [ ] [routing-later] 자동 라우팅, 부하 라우팅, cloud fallback, 품질 평가 feedback은 후속 마일스톤으로 분리되어 있다.
|
||||
- [x] [edge-node-ollama-path] Edge OpenAI-compatible API 요청이 Edge-Node 소켓을 통해 Node Ollama adapter로 전달되는 E2E 경로가 검증되어 있다.
|
||||
- [x] [manual-node-select] Node 선택은 자동 라우팅이 아니라 `openai.node` 또는 단일 Node fallback 같은 수동 설정 기준으로 동작한다.
|
||||
- [x] [manual-model-select] model 선택은 요청 `model` 또는 `openai.target` 고정값을 통해 수동으로 검증할 수 있다.
|
||||
- [x] [routing-later] 자동 라우팅, 부하 라우팅, cloud fallback, 품질 평가 feedback은 후속 마일스톤으로 분리되어 있다.
|
||||
|
||||
### Epic: [ollama-openai-cycle] Ollama OpenAI-Compatible Full Cycle
|
||||
|
||||
- [ ] [models-list] Edge `/v1/models`가 Node/Ollama에서 사용 가능한 model 목록을 외부 client가 기대하는 OpenAI-compatible 형식으로 제공한다.
|
||||
- [ ] [chat-cycle] Edge `/v1/chat/completions` 호출이 내부 `adapter=ollama`, `target=<model>` 실행으로 변환되어 Node Ollama adapter까지 왕복한다.
|
||||
- [ ] [streaming-cycle] non-streaming 응답과 streaming SSE 응답이 일반 OpenAI-compatible client에서 사용할 수 있는 형태로 동작한다.
|
||||
- [ ] [model-target-map] `openai.target` 고정 라우팅과 요청 `model` 기반 라우팅의 우선순위가 명확하며 Ollama model 선택 경험과 충돌하지 않는다.
|
||||
- [ ] [option-pass-through] temperature, top_p, max_tokens, stop, stream 등 1차 지원 option의 매핑과 미지원 option 응답 정책이 정리되어 있다.
|
||||
- [ ] [thinking-passthrough] Ollama `message.thinking` stream이 Edge-Node `RunEvent`에서 content delta와 구분되어 전달된다.
|
||||
- [ ] [openai-reasoning-ux] Edge OpenAI-compatible streaming/non-streaming 응답이 thinking을 `reasoning_content` 계열 확장 필드로 노출하고, 답변 content stream과 섞지 않는다.
|
||||
- [ ] [think-control] `think:false`는 기본값이 아니라 명시 옵션 또는 후속 client별 제어로만 다루며, 기본 UX는 thinking 보존으로 둔다.
|
||||
- [ ] [ollama-command-pass-through] Ollama 관련 모델 조회, 상태, 관리 command를 Edge OpenAI-compatible 표면에서 어디까지 pass-through할지 1차 범위가 정의되어 있다.
|
||||
- [ ] [openai-ollama-aux-smoke] fake Ollama 기반 보조 smoke가 OpenAI-compatible 입력 표면과 edge-node relay의 최소 생존을 확인한다.
|
||||
- [ ] [toki-labs-field-flow] 공통 필드 테스트 환경에서 `toki-labs` Ollama `gemma4:26b` 모델을 사용해 사용자가 직접 재현 가능한 bin edge-node 흐름이 문서화되고 검증된다.
|
||||
- [x] [models-list] Edge `/v1/models`가 Node/Ollama에서 사용 가능한 model 목록을 외부 client가 기대하는 OpenAI-compatible 형식으로 제공한다.
|
||||
- [x] [chat-cycle] Edge `/v1/chat/completions` 호출이 내부 `adapter=ollama`, `target=<model>` 실행으로 변환되어 Node Ollama adapter까지 왕복한다.
|
||||
- [x] [streaming-cycle] non-streaming 응답과 streaming SSE 응답이 일반 OpenAI-compatible client에서 사용할 수 있는 형태로 동작한다.
|
||||
- [x] [model-target-map] `openai.target` 고정 라우팅과 요청 `model` 기반 라우팅의 우선순위가 명확하며 Ollama model 선택 경험과 충돌하지 않는다.
|
||||
- [x] [option-pass-through] temperature, top_p, max_tokens, stop, stream 등 1차 지원 option의 매핑과 미지원 option 응답 정책이 정리되어 있다.
|
||||
- [x] [thinking-passthrough] Ollama `message.thinking` stream이 Edge-Node `RunEvent`에서 content delta와 구분되어 전달된다.
|
||||
- [x] [openai-reasoning-ux] Edge OpenAI-compatible streaming/non-streaming 응답이 thinking을 `reasoning_content` 계열 확장 필드로 노출하고, 답변 content stream과 섞지 않는다.
|
||||
- [x] [think-control] `think:false`는 기본값이 아니라 명시 옵션 또는 후속 client별 제어로만 다루며, 기본 UX는 thinking 보존으로 둔다.
|
||||
- [x] [ollama-command-pass-through] Ollama 관련 모델 조회, 상태, 관리 command를 Edge OpenAI-compatible 표면에서 어디까지 pass-through할지 1차 범위가 정의되어 있다.
|
||||
- [x] [openai-ollama-aux-smoke] fake Ollama 기반 보조 smoke가 OpenAI-compatible 입력 표면과 edge-node relay의 최소 생존을 확인한다.
|
||||
- [x] [toki-labs-field-flow] 공통 필드 테스트 환경에서 `toki-labs` Ollama `gemma4:26b` 모델을 사용해 사용자가 직접 재현 가능한 bin edge-node 흐름이 문서화되고 검증된다.
|
||||
|
||||
### Epic: [protocol-boundary] Protocol Boundary
|
||||
|
||||
- [ ] [a2a-boundary] A2A API는 외부 agent의 작업 위임 표면으로 제한한다.
|
||||
- [ ] [nomadcode-a2a] NomadCode의 A2A 도입 시점은 이 Milestone에서 강제하지 않고 별도 판단으로 남긴다.
|
||||
- [ ] [native-protocol] IOP native protocol은 proto-socket 기반 내부/운영 호출 기준으로 두고 OpenAI-compatible UX에 운영 제어 기능을 억지로 싣지 않는다.
|
||||
- [ ] [optimization-later] RAG, MCP, web search, output validation은 기본 serving/load routing 이후 단계로 명시한다.
|
||||
- [x] [a2a-boundary] A2A API는 외부 agent의 작업 위임 표면으로 제한한다.
|
||||
- [x] [nomadcode-a2a] NomadCode의 A2A 도입 시점은 이 Milestone에서 강제하지 않고 별도 판단으로 남긴다.
|
||||
- [x] [native-protocol] IOP native protocol은 proto-socket 기반 내부/운영 호출 기준으로 두고 OpenAI-compatible UX에 운영 제어 기능을 억지로 싣지 않는다.
|
||||
- [x] [optimization-later] RAG, MCP, web search, output validation은 기본 serving/load routing 이후 단계로 명시한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] Edge `/v1/models`와 `/v1/chat/completions`를 통해 Node의 Ollama serving을 조회하고 사용할 수 있다.
|
||||
- [ ] 외부 agent/client가 Edge endpoint를 일반 OpenAI-compatible 또는 Ollama-backed endpoint처럼 설정해 한 차례 모델 조회와 chat completion을 완료할 수 있다.
|
||||
- [ ] streaming/non-streaming 응답, thinking/reasoning passthrough, model-to-target 매핑, 1차 option pass-through, unsupported option 응답 정책이 검증되어 있다.
|
||||
- [ ] `gemma4:26b`처럼 thinking을 먼저 내보내는 모델에서도 사용자는 thinking 중임을 볼 수 있고, content가 시작되면 답변 stream을 자연스럽게 받을 수 있다.
|
||||
- [ ] `bin/edge.sh`와 `bin/node.sh`를 실제로 연결해 Edge -> Node -> Ollama 흐름을 재현할 수 있다.
|
||||
- [ ] 최종 완료 검증은 fake Ollama smoke가 아니라 사용자가 직접 테스트하는 방식과 같은 `toki-labs` 실제 Ollama 필드 흐름으로 수행된다.
|
||||
- [ ] 공통 필드 테스트 환경의 Node host, Ollama endpoint, model, edge/node 임시 config 기준이 문서화되어 있다.
|
||||
- [ ] Node/model 선택은 수동 설정으로 검증하고, 자동 라우팅과 부하 라우팅은 후속 작업으로 분리되어 있다.
|
||||
- [ ] 외부 API 표면과 IOP native protocol의 역할이 충돌하지 않는다.
|
||||
- [ ] 다음 단계인 model routing/fallback 최적화로 넘어갈 선행 조건이 정의된다.
|
||||
- [x] Edge `/v1/models`와 `/v1/chat/completions`를 통해 Node의 Ollama serving을 조회하고 사용할 수 있다.
|
||||
- [x] 외부 agent/client가 Edge endpoint를 일반 OpenAI-compatible 또는 Ollama-backed endpoint처럼 설정해 한 차례 모델 조회와 chat completion을 완료할 수 있다.
|
||||
- [x] streaming/non-streaming 응답, thinking/reasoning passthrough, model-to-target 매핑, 1차 option pass-through, unsupported option 응답 정책이 검증되어 있다.
|
||||
- [x] `gemma4:26b`처럼 thinking을 먼저 내보내는 모델에서도 사용자는 thinking 중임을 볼 수 있고, content가 시작되면 답변 stream을 자연스럽게 받을 수 있다.
|
||||
- [x] `bin/edge.sh`와 `bin/node.sh`를 실제로 연결해 Edge -> Node -> Ollama 흐름을 재현할 수 있다.
|
||||
- [x] 최종 완료 검증은 fake Ollama smoke가 아니라 사용자가 직접 테스트하는 방식과 같은 `toki-labs` 실제 Ollama 필드 흐름으로 수행된다.
|
||||
- [x] 공통 필드 테스트 환경의 Node host, Ollama endpoint, model, edge/node 임시 config 기준이 문서화되어 있다.
|
||||
- [x] Node/model 선택은 수동 설정으로 검증하고, 자동 라우팅과 부하 라우팅은 후속 작업으로 분리되어 있다.
|
||||
- [x] 외부 API 표면과 IOP native protocol의 역할이 충돌하지 않는다.
|
||||
- [x] 다음 단계인 model routing/fallback 최적화로 넘어갈 선행 조건이 정의된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 상태: 승인됨
|
||||
- 요청일: 2026-05-25
|
||||
- 완료 근거: 실제 `toki-labs` Ollama `gemma4:26b` 필드 흐름에서 `/v1/models`, non-streaming chat, streaming SSE, reasoning/content 분리, `data: [DONE]`, Ollama native `/api/tags`와 `/api/show` passthrough, Ollama option passthrough가 검증되었다. 다음 routing/fallback 선행 조건도 하드코딩 route test, 모든 에러 fallback raising, Ollama 기준 model profile, 수동 node/target 독립 제약으로 정리했다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
- [x] 사용자가 완료 결과를 확인했다
|
||||
- [x] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 2026-05-25 사용자 확인에 따라 완료 처리하고 archive 이동한다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
@ -104,6 +104,17 @@ Edge에 OpenAI API 방식으로 접속한 외부 agent/client가 Edge-Node 소
|
|||
- 표준선(선택): Ollama thinking은 `RunEvent.type=reasoning_delta` 같은 별도 event type으로 content delta와 구분하고, OpenAI-compatible SSE에서는 `delta.reasoning_content` 확장 필드로 노출한다.
|
||||
- 표준선(선택): non-streaming 응답은 최종 `message.content`에 답변만 담고, thinking은 `message.reasoning_content` 확장 필드로 분리한다.
|
||||
- 표준선(선택): `think:false`는 테스트 우회 기본값으로 쓰지 않고, 명시 요청 또는 후속 설정 항목으로만 고려한다.
|
||||
- 진행 근거(2026-05-25): `b7d636c`에서 `EventTypeReasoningDelta`, Ollama `message.thinking` 분리 event, OpenAI-compatible `reasoning_content` streaming/non-streaming 응답, fake Ollama smoke의 reasoning 검증이 반영되어 있다.
|
||||
- 검증 근거(2026-05-25): `go test ./apps/edge/internal/openai ./apps/node/internal/adapters/ollama`, `scripts/e2e-openai-ollama.sh` 통과.
|
||||
- 검증 근거(2026-05-25): 임시 Edge/Node config로 `bin/edge.sh`와 `bin/node.sh`를 실제 실행하고 `http://192.168.0.97:11434`의 `gemma4:26b`를 호출했다. `/v1/models`는 `gemma4:26b`를 반환했고, non-streaming 응답 content는 `IOP_OLLAMA_E2E_OK`, reasoning 길이는 681 bytes였다. streaming SSE는 content delta를 이어 붙였을 때 `IOP_OLLAMA_STREAM_OK`, reasoning 길이는 695 bytes였고 `data: [DONE]`을 확인했다.
|
||||
- 검증 메모(2026-05-25): `ssh toki@toki-labs.com` 원격 host는 접근 가능하지만 확인한 기본 경로에는 repo가 없고 `go`도 PATH에 없어, 이번 검증은 로컬 임시 Node가 실제 Ollama endpoint를 호출하는 방식으로 수행했다.
|
||||
- 정책 확정(2026-05-25): OpenAI generation option과 Ollama 관련 command pass-through는 IOP가 별도 의미를 재정의하지 않고 Ollama가 지원하는 형식을 그대로 전달/노출하는 것을 기준으로 한다.
|
||||
- 선행 조건 확정(2026-05-25): routing/fallback 최적화의 route test는 초기에는 하드코딩한다. 모든 에러는 fallback raising 조건이며, 에러 분류는 관측/정책 판단을 위한 구분일 뿐 fallback 트리거 자체를 막지 않는다.
|
||||
- 선행 조건 확정(2026-05-25): 품질 평가, cloud fallback, 자동 부하 분산은 이 Milestone의 후속 단계에서 제외한다.
|
||||
- 선행 조건 확정(2026-05-25): model profile은 IOP 고유 profile taxonomy를 새로 만들지 않고 Ollama가 제공/지원하는 model 정보와 option/capability 형식을 기준으로 맞춘다.
|
||||
- 선행 조건 확정(2026-05-25): 수동 `node`와 `target`은 우선순위 경쟁 관계가 아니라 독립 제약이다. 명시된 node에 target이 없거나 실행할 수 없으면 자동 보정하지 않고 에러로 처리한다.
|
||||
- 검증 근거(2026-05-25): 실제 `toki-labs` Ollama field 환경에서 option 포함 non-streaming 응답 content는 `IOP_OLLAMA_OPTIONS_OK`, streaming SSE는 content delta를 이어 붙였을 때 `IOP_OLLAMA_STREAM_OK`, reasoning 길이는 537 bytes였고 `data: [DONE]`을 확인했다. Ollama native `/api/tags`는 6개 model을 반환했고 `/api/show`는 `gemma4:26b` model info를 반환했다.
|
||||
- 완료 후보(2026-05-25): 모든 필수 기능과 완료 기준이 evidence와 함께 충족되어 `[검토중]`으로 전환한다.
|
||||
- 선행 작업: Edge 입력 표면, CLI Automation Runtime 안정화
|
||||
- 후속 작업: 모델 라우팅/fallback 최적화, 지식/도구 정책/검증 최적화
|
||||
- 확인 필요: cloud fallback 도입 시점, Responses API 세부 호환 범위, 모델 품질 평가와 routing feedback의 초기 신호
|
||||
|
|
@ -13,9 +13,9 @@
|
|||
|
||||
## 활성 Milestone
|
||||
|
||||
- [진행중] Ollama E2E 서빙 안정화
|
||||
- [계획] 지식, 도구 정책, 검증 최적화
|
||||
- Phase: `agent-ops/roadmap/phase/serving-routing-optimization/PHASE.md`
|
||||
- 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md`
|
||||
- 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md`
|
||||
|
||||
- [검토중] Specialized Agent proto-socket 연결 기반
|
||||
- Phase: `agent-ops/roadmap/phase/automation-runtime-bridge/PHASE.md`
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ OTO는 build/deploy domain agent의 대표 후보로 둔다.
|
|||
- OTO가 확정한 Linux MVP bootstrap command, release asset, config path, background 실행 기준을 Edge bootstrap 발급 계약으로 수용
|
||||
- Edge가 OTO agent 생성, command 발급, registration accepted, 최초 heartbeat 기반 online 상태를 구분해 표현하는 기준 정리
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [agent-boundary] Agent Boundary and Enrollment Path
|
||||
|
||||
|
|
@ -41,32 +41,23 @@ OTO는 build/deploy domain agent의 대표 후보로 둔다.
|
|||
|
||||
### Epic: [bootstrap-command] Bootstrap Command Contract
|
||||
|
||||
- [ ] [bootstrap-command] Edge가 OTO agent 생성 후 Linux 대상에서 바로 실행할 one-line bootstrap command를 발급하는 계약이 정의되어 있다.
|
||||
- [ ] [bootstrap-command] Edge가 OTO agent 생성과 bootstrap command 발급 상태를 표현하고 Linux 대상에서 바로 실행할 one-line bootstrap command를 발급하는 계약이 정의되어 있다.
|
||||
- [ ] [agent-record] Edge-side agent record가 `agent_kind=oto-agent`, agent id, alias, enrollment token, bootstrap 발급 상태를 소유하는 기준이 정의되어 있다.
|
||||
- [ ] [oto-contract] OTO의 bootstrap script 인자, release asset 이름, config path, background 실행 기준이 iop Edge 발급 계약에 반영되어 있다.
|
||||
- [ ] [registration-state] Edge 등록 accepted와 최초 heartbeat 도착을 online 판정으로 분리하고, bootstrap issued/registered/online/failure 상태 기준이 정의되어 있다.
|
||||
- [ ] [oto-contract] OTO의 bootstrap script 인자, release asset 이름, config path, background 실행 기준이 iop Edge 발급 계약에 반영되어 있고 OTO 로드맵 계약과 충돌하지 않는다.
|
||||
- [ ] [registration-state] bootstrap command 실행 후 agent config/service 등록과 Edge outbound enrollment 흐름이 문서화되고, `bootstrap issued`, `registration accepted`, `first heartbeat received`, `online`, `failed` 상태 기준이 구분되어 있다.
|
||||
- [ ] [insecure-policy] HTTP local/insecure bootstrap 모드는 명시 선택과 추가 검증 조건을 요구한다.
|
||||
|
||||
### Epic: [ownership-boundary] IOP and OTO Ownership
|
||||
|
||||
- [ ] [ownership] iop와 OTO의 책임 경계가 문서화되어 있다.
|
||||
- [ ] [iop-ownership] iop는 Edge agent registry, bootstrap command 발급, credential, 보안 검증 기준을 소유한다.
|
||||
- [ ] [oto-ownership] OTO는 build/deploy domain agent의 설치 산출물, 설정 생성, YAML run/capability/event 의미를 소유한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] Edge가 agent 생성과 bootstrap command 발급 상태를 표현할 수 있다.
|
||||
- [ ] bootstrap command 실행 후 agent config/service 등록과 Edge outbound enrollment 흐름이 문서화된다.
|
||||
- [ ] OTO agent와 generic Node의 책임 경계가 registry와 protocol 수준에서 구분된다.
|
||||
- [ ] Edge가 발급하는 OTO bootstrap command의 필수/선택 인자와 OTO release asset 선택 규칙이 OTO 로드맵 계약과 충돌하지 않는다.
|
||||
- [ ] Edge-side 상태 모델에서 `bootstrap issued`, `registration accepted`, `first heartbeat received`, `online`, `failed`가 구분된다.
|
||||
- [ ] OTO의 Edge bootstrap 계약 문서가 이 Milestone의 산출물을 참조해 재개 가능한 상태가 된다.
|
||||
- [ ] [ownership] OTO agent와 generic Node의 책임 경계가 registry와 protocol 수준에서 구분되고 iop와 OTO의 책임 경계가 문서화되어 있다.
|
||||
- [ ] [iop-ownership] iop는 Edge agent registry, bootstrap command 발급, credential, 보안 검증 기준을 소유한다.
|
||||
- [ ] [oto-ownership] OTO는 build/deploy domain agent의 설치 산출물, 설정 생성, YAML run/capability/event 의미를 소유하고, OTO의 Edge bootstrap 계약 문서는 이 Milestone 산출물을 참조해 재개 가능하다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 완료 근거: 모든 기능 Task가 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -30,27 +30,22 @@ Edge는 terminal session broker가 되고, 대상에 도달 가능한 Node가 SS
|
|||
- 대상 접근 경로와 credential이 있는 경우만 제어할 수 있다는 전제 명시
|
||||
- target allowlist, timeout, 동시 실행 제한, session audit 요구사항 초안
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [bridge-boundary] Remote Terminal Bridge Boundary
|
||||
|
||||
- [ ] [agentless-boundary] 원격 터미널 브리지는 완전 agentless 제어가 아니라 target device에 IOP agent를 설치하지 않는 운영 경로로 정의한다.
|
||||
- [ ] [edge-broker] Control Plane/Portal/운영 CLI는 Edge에 terminal session을 요청하고, Node에 직접 붙지 않는다.
|
||||
- [ ] [node-transport] Node terminal transport는 adapter/profile 또는 별도 adapter 경계에서 실행된다.
|
||||
- [ ] [terminal-events] stdin/stdout/stderr, resize, signal, close lifecycle을 IOP native protocol에서 표현하는 방향을 검증한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] 허용된 target profile을 통해 Edge에서 remote shell session을 열고 입출력을 relay할 수 있다.
|
||||
- [ ] session lifecycle과 adapter execution stream의 경계가 문서화된다.
|
||||
- [ ] 보안 전제(target allowlist, credential, timeout, audit)가 milestone 산출물에 포함된다.
|
||||
- [ ] OpenAI-compatible API나 A2A task payload에 terminal 제어 기능을 억지로 싣지 않는다.
|
||||
- [ ] [terminal-events] stdin/stdout/stderr, resize, signal, close lifecycle과 adapter execution stream 경계를 IOP native protocol에서 표현한다. 검증: 허용된 target profile로 Edge remote shell session을 열고 입출력 relay가 되는지 확인한다.
|
||||
- [ ] [security-boundary] target allowlist, credential, timeout, audit 보안 전제를 milestone 산출물에 포함한다.
|
||||
- [ ] [surface-boundary] OpenAI-compatible API나 A2A task payload에 terminal 제어 기능을 억지로 싣지 않는다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -28,39 +28,31 @@ OTO 쪽 Dart `proto_socket` 연결과 iop Edge 등록 smoke가 이미 시작된
|
|||
- `RunRequest/RunEvent`, `CancelRequest`, `NodeCommandRequest/Response`의 envelope 재사용 범위와 OTO 전용 build/deploy payload, artifact/log/progress 의미의 분리 기준을 잡는다.
|
||||
- OTO Dart `proto_socket` consumer와 iop Go Edge transport 사이의 smoke 범위를 registration에서 run/command까지 확장할지 판단한다.
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [node-compatible-flow] Node-Compatible Flow Mapping
|
||||
|
||||
- [x] [node-flow-map] 기존 Edge-Node 등록, config, run/event, command, heartbeat 흐름이 specialized agent 관점에서 매핑되어 있다.
|
||||
- [x] [oto-interop] OTO Dart `proto_socket` consumer가 iop Edge의 기존 `RegisterRequest/RegisterResponse` 등록 smoke 경로를 통과하는 근거가 확인되어 있다.
|
||||
- [x] [node-flow-map] 기존 Edge-Node 등록, config, run/event, command, heartbeat 흐름이 specialized agent 관점에서 매핑되어 있고 Agent Bootstrap과 OTO 등록이 통신 기반을 추측하지 않고 후속 계약을 작성할 수 있다.
|
||||
- [x] [oto-interop] OTO Dart `proto_socket` consumer가 iop Edge의 기존 `RegisterRequest/RegisterResponse` 등록 smoke 경로를 통과하는 근거가 확인되어 있고 OTO의 proto-socket 통신 기반 Milestone과 같은 책임 경계를 참조한다. 검증: OTO Dart client의 iop Edge registration smoke 근거와 후속 run/command smoke 요구 범위가 문서에 남아 있다.
|
||||
- [x] [runtime-path-boundary] generic Node, specialized domain agent, remote terminal bridge 경로가 제품/transport 수준에서 구분된다.
|
||||
- [x] [bootstrap-precondition] Agent Bootstrap과 OTO 등록이 의존할 transport/handshake 선행 조건이 명확하다.
|
||||
|
||||
### Epic: [agent-kind-registry] Agent Kind와 Registry Entry
|
||||
|
||||
- [x] [agent-kind] Edge registry에서 generic Node와 specialized domain agent를 구분하는 기준 후보가 정리되어 있다.
|
||||
- [x] [agent-kind] Edge registry에서 generic Node와 specialized domain agent를 구분하는 기준 후보와 proto field 추가가 필요한 시점/조건이 정리되어 있다.
|
||||
- [x] [registry-entry] connected entry가 agent kind, capability, lifecycle을 가질 수 있는지, 기존 `NodeEntry`/registry를 확장할지 별도 registry로 분리할지 기준이 정리되어 있다.
|
||||
- [x] [reconnect-policy] duplicate connection, reconnect, 기존 연결 대체/거부 정책이 specialized agent 관점에서 정리되어 있다.
|
||||
|
||||
### Epic: [config-message-boundary] Config와 Message Boundary
|
||||
|
||||
- [x] [config-boundary] `RegisterResponse`의 `NodeConfigPayload`를 OTO에 어떻게 적용할지, 최소 config와 OTO 전용 config 분리 기준이 정리되어 있다.
|
||||
- [x] [message-boundary] 기존 Node 메시지 재사용과 specialized agent 전용 메시지 분리 기준이 문서화되어 있다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [x] Agent Bootstrap과 OTO 등록이 통신 기반을 추측하지 않고 후속 계약을 작성할 수 있다.
|
||||
- [x] OTO의 proto-socket 통신 기반 Milestone과 같은 책임 경계를 참조한다.
|
||||
- [x] generic Node, specialized domain agent, remote terminal bridge 경로가 제품/transport 수준에서 구분된다.
|
||||
- [x] OTO Dart client의 iop Edge registration smoke를 기준으로, 후속 run/command smoke를 어디까지 요구할지 결정되어 있다.
|
||||
- [x] specialized agent를 처음에는 node-compatible handshake로 받아들이되, proto field 추가가 필요한 시점과 조건이 정리되어 있다.
|
||||
- [x] proto schema나 registry schema를 구현하기 전에 결정해야 할 후보와 확인 필요 항목이 정리되어 있다.
|
||||
- [x] [message-boundary] 기존 Node 메시지 재사용과 specialized agent 전용 메시지 분리 기준이 문서화되어 있고 proto schema나 registry schema 구현 전 결정 후보와 확인 필요 항목이 정리되어 있다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 요청됨
|
||||
- 요청일: 2026-05-25
|
||||
- 완료 근거: 필수 Epic/Task와 완료 기준이 모두 충족되어 완료 후보로 정리했다. 기존 Edge-Node proto-socket 재사용, Edge-side agent kind 판별, config/message boundary, reconnect 정책, bootstrap 선행 조건이 계약 결정에 반영되어 있다. OTO 직접 연결 Phase와 remote terminal bridge Phase 경계를 참조해 generic Node, specialized agent, agentless terminal bridge 경로를 분리했다.
|
||||
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 충족되어 완료 후보로 정리했다. 기존 Edge-Node proto-socket 재사용, Edge-side agent kind 판별, config/message boundary, reconnect 정책, bootstrap 선행 조건이 계약 결정에 반영되어 있다. OTO 직접 연결 Phase와 remote terminal bridge Phase 경계를 참조해 generic Node, specialized agent, agentless terminal bridge 경로를 분리했다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -31,13 +31,13 @@ NomadCode의 `Client Integration Standardization` 결과를 IOP Flutter client
|
|||
- iOS는 동일 Dart API 위의 후속 native 확장 대상으로 문서화
|
||||
- NomadCode에서 복제할 skeleton과 IOP에서 치환할 product-specific 항목 문서화
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [client-clone] Client standard clone
|
||||
|
||||
NomadCode client 표준을 IOP client에 맞게 복제한다.
|
||||
|
||||
- [ ] [module-name] Flutter host 경로와 모듈명을 `client` 기준으로 정리한다.
|
||||
- [ ] [module-name] IOP Flutter host 경로와 모듈명은 `client` 기준 모듈/문서 표준을 따른다.
|
||||
- [ ] [bootstrap] Web + Android 우선 bootstrap과 platform-specific initialization guard를 반영한다.
|
||||
- [ ] [folder-layout] `src/app`, `src/integrations`, `src/features` 기준의 client skeleton을 정리한다.
|
||||
|
||||
|
|
@ -45,9 +45,9 @@ NomadCode client 표준을 IOP client에 맞게 복제한다.
|
|||
|
||||
공통 integration 경계를 IOP 운영면에 맞춰 연결한다.
|
||||
|
||||
- [ ] [proto-socket] proto-socket connection lifecycle과 endpoint configuration을 client integration으로 정리한다.
|
||||
- [ ] [proto-socket] proto-socket connection lifecycle과 endpoint configuration 책임 경계가 host/plugin/control-plane 사이에서 충돌하지 않도록 client integration으로 정리한다.
|
||||
- [ ] [mattermost-push] Mattermost push는 plugin runtime을 사용하고 host는 Firebase config, auth/token/signing key, routing callback만 소유한다.
|
||||
- [ ] [platform-targets] Web과 Android를 1차 target으로 검증하고 iOS 후속 확장 경계를 문서화한다.
|
||||
- [ ] [platform-targets] Web과 Android를 1차 target으로 두고 iOS는 동일 Dart API 위의 후속 확장 대상으로 문서화한다. 검증: Web build와 Android integration이 같은 Dart bootstrap 기준에서 설명되고 확인되는지 점검한다.
|
||||
|
||||
### Epic: [handoff] Clone handoff
|
||||
|
||||
|
|
@ -55,15 +55,7 @@ NomadCode 표준과 IOP 차이를 추적 가능하게 남긴다.
|
|||
|
||||
- [ ] [clone-checklist] NomadCode에서 가져올 항목과 IOP에서 치환할 항목을 checklist로 남긴다.
|
||||
- [ ] [naming] IOP 제품 표면에서는 Portal 명칭을 강조하지 않고 client/product surface 기준으로 정리한다.
|
||||
- [ ] [related-locks] Flutter client 진행 milestone은 이 milestone의 표준 적용 범위가 확정되기 전까지 잠금 상태로 둔다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] IOP Flutter host가 `client` 기준 모듈/문서 표준을 따른다.
|
||||
- [ ] Web build와 Android integration이 같은 Dart bootstrap 기준에서 설명되고 검증된다.
|
||||
- [ ] proto-socket과 Mattermost push integration 책임 경계가 host/plugin/control-plane 사이에서 충돌 없이 설명된다.
|
||||
- [ ] iOS가 Android-only 예외가 아니라 동일 Dart API 위의 후속 확장 대상으로 문서화된다.
|
||||
- [ ] 기존 Control Plane client milestone과 충돌하는 범위가 잠금 또는 후속 작업으로 정리되어 있다.
|
||||
- [ ] [related-locks] 기존 Control Plane client milestone과 충돌하는 범위는 이 milestone의 표준 적용 범위가 확정되기 전까지 잠금 또는 후속 작업으로 정리한다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
|
|
|
|||
|
|
@ -33,33 +33,29 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되
|
|||
- remote terminal bridge session 생성/조회/권한 확인 표면
|
||||
- IOP 책임 범위 안의 model serving, automation, session, routing, policy, history, audit 운영 UX
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [edge-ops-boundary] Edge Operations Boundary
|
||||
|
||||
- [ ] [edge-mediated] Control Plane은 Node를 직접 연결하거나 직접 스케줄링하지 않는다.
|
||||
- [ ] [edge-status] Control Plane은 하나 이상의 Edge 연결 상태를 조회할 수 있다. 검증: Edge 연결 상태 조회 경로가 동작하는지 확인한다.
|
||||
- [ ] [edge-command-events] Edge 명령 전달과 이벤트 수신 흐름이 IOP Wire Protocol 기준으로 문서화된다.
|
||||
- [ ] [portal-via-cp] Client는 Control Plane을 통해 Edge 운영 상태를 조회한다.
|
||||
|
||||
### Epic: [portal-wire-surface] Client Wire Surface
|
||||
|
||||
- [x] [portal-boundary] Flutter client는 IOP 자체 운영 UI/UX이며 NomadCode에 맞춘 전용 shell로 만들지 않는다.
|
||||
- [ ] [client-status] Client는 Edge/Node/agent 상태를 사용자에게 보여줄 수 있다. 검증: Client 상태 화면 또는 상태 표시 경로를 확인한다.
|
||||
- [x] [server-bridge] 브라우저 직접 TCP가 어려운 구간은 Control Plane server-side bridge를 둔다.
|
||||
- [x] [portal-wire-hello] Client-Control Plane proto-socket WebSocket hello handshake scaffold가 구현되고 테스트되어 있다.
|
||||
- [x] [portal-wire-hello] Client-Control Plane proto-socket WebSocket hello handshake scaffold가 구현되고 테스트되어 있다. 검증: Control Plane과 client 테스트에서 hello handshake가 통과한다.
|
||||
- [x] [http-boundary] `net/http`는 health/readiness/bootstrap 같은 보조 endpoint 용도로 유지한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] Control Plane이 하나 이상의 Edge 연결 상태를 조회할 수 있다.
|
||||
- [ ] Client가 Edge/Node/agent 상태를 사용자에게 보여줄 수 있다.
|
||||
- [ ] Edge 명령 전달과 이벤트 수신 흐름이 IOP Wire Protocol 기준으로 문서화된다.
|
||||
- [x] Control Plane과 Flutter client의 배포/검증 절차가 `docs/deploy-dev.md`와 일치한다.
|
||||
- [x] Client-Control Plane proto-socket WebSocket hello handshake가 Control Plane과 client 테스트에서 검증된다.
|
||||
- [x] [deploy-docs] Control Plane과 Flutter client의 배포/검증 절차가 `docs/deploy-dev.md`와 일치한다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -31,26 +31,22 @@ Multi-edge scheduling과 fleet-wide state ownership은 Control Plane과 IOP nati
|
|||
- OTO 같은 domain agent의 build/deploy 상태, artifact, capability 포함
|
||||
- fleet-wide 명령과 운영 리포트 후보
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [fleet-ops-boundary] Fleet Operations Boundary
|
||||
|
||||
- [ ] [native-fleet] Multi-edge 운영 명령과 이벤트 집계는 IOP native protocol을 기준으로 설계한다.
|
||||
- [ ] [edge-status-view] Control Plane은 여러 Edge 상태를 구분해 조회하고 표시할 수 있다. 검증: 두 개 이상의 Edge 상태가 구분되는 조회/표시 경로를 확인한다.
|
||||
- [ ] [history-agent-state] Edge별 실행 이력과 domain agent 상태가 운영 화면에서 구분된다.
|
||||
- [ ] [openai-routing] OpenAI-compatible 표면은 특정 Edge/adapter로 라우팅되는 inference 호환 경로로 제한한다.
|
||||
- [ ] [a2a-routing] A2A 표면은 특정 Edge/adapter로 위임되는 agent task 경로로 제한한다.
|
||||
- [ ] [edge-ownership] Edge는 자신의 로컬 런타임 상태와 registry 소유권을 유지한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] Control Plane이 여러 Edge 상태를 구분해 조회하고 표시할 수 있다.
|
||||
- [ ] Edge별 실행 이력과 domain agent 상태가 운영 화면에서 구분된다.
|
||||
- [ ] fleet-wide 기능이 Edge의 로컬 상태 소유권을 침범하지 않는다.
|
||||
- [ ] [edge-ownership] Edge는 자신의 로컬 런타임 상태와 registry 소유권을 유지하고 fleet-wide 기능은 이를 침범하지 않는다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -31,26 +31,22 @@
|
|||
- timeout, 동시 실행 제한, 입력/출력 기록 정책
|
||||
- metrics/event API와 운영 리포트 후보
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [policy-audit-boundary] Policy and Audit Boundary
|
||||
|
||||
- [ ] [edge-source] Edge는 로컬 런타임 상태와 실행 이력의 원본을 유지한다.
|
||||
- [ ] [cp-aggregation] Control Plane은 Edge 데이터를 조회/집계하되 Edge 내부 상태 소유권을 대체하지 않는다.
|
||||
- [ ] [terminal-audit] 원격 터미널 브리지는 target allowlist, credential 관리, command/session audit를 필수로 요구한다.
|
||||
- [ ] [terminal-audit] 원격 터미널 브리지는 target allowlist, credential 관리, command/session audit를 필수로 요구하고, 실행 요청, command, terminal session, bootstrap/enrollment audit event 초안을 포함한다.
|
||||
- [ ] [policy-location] 권한과 policy가 Edge/Control Plane/Node 경계 중 어디에서 적용되는지 문서화된다.
|
||||
- [ ] [history-attach] 기존 실행 파이프라인을 깨지 않고 history/audit 수집을 붙일 수 있는 경계를 정의한다.
|
||||
- [ ] [schema-evidence] policy/audit 상세 schema는 구현 단계에서 명확한 evidence와 함께 확정한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] 실행 요청, command, terminal session, bootstrap/enrollment에 대한 audit event 초안이 있다.
|
||||
- [ ] 권한과 policy가 Edge/Control Plane/Node 경계 중 어디에서 적용되는지 문서화된다.
|
||||
- [ ] 기존 실행 파이프라인을 깨지 않고 history/audit 수집을 붙일 수 있다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 완료 근거: 모든 기능 Task가 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
|
||||
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
|
||||
|
||||
- [진행중] Ollama E2E 서빙 안정화
|
||||
- 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md`
|
||||
- 요약: Edge OpenAI-compatible API를 통해 Node에서 serving 중인 Ollama를 일반 OpenAI/Ollama client 경험에 가깝게 조회, 선택, 호출하는 E2E 한 사이클을 먼저 완성하고, 자동 라우팅과 cloud fallback은 후속으로 미룬다.
|
||||
- [완료] Ollama E2E 서빙 안정화
|
||||
- 경로: `agent-ops/roadmap/archive/phase/serving-routing-optimization/milestones/model-serving-load-routing.md`
|
||||
- 요약: Edge OpenAI-compatible API를 통해 Node에서 serving 중인 Ollama를 일반 OpenAI/Ollama client 경험에 가깝게 조회, 선택, 호출하는 E2E 한 사이클과 Ollama option/API passthrough를 완료했다. 자동 라우팅, cloud fallback, 품질 평가는 후속으로 미룬다.
|
||||
|
||||
- [계획] 지식, 도구 정책, 검증 최적화
|
||||
- 경로: `agent-ops/roadmap/phase/serving-routing-optimization/milestones/knowledge-tool-validation-optimization.md`
|
||||
|
|
|
|||
|
|
@ -30,26 +30,21 @@ NomadCode는 이 계층의 소비자 중 하나이며, 이 Milestone은 특정 A
|
|||
- output validation, retry/fallback, token/속도/품질 최적화 기준
|
||||
- 모델 호출 로그와 품질 평가 결과를 후속 routing 판단에 연결하는 방향
|
||||
|
||||
## 필수 기능
|
||||
## 기능
|
||||
|
||||
### Epic: [knowledge-policy] Knowledge and Tool Policy
|
||||
|
||||
- [ ] [knowledge-policy] RAG, context, web search, MCP, tool policy, validation은 IOP 책임으로 정의한다.
|
||||
- [ ] [client-surface] 외부 client는 NomadCode 전용 metadata가 아니라 OpenAI-compatible, A2A, IOP native protocol 경계 중 맞는 표면을 통해 IOP를 소비한다.
|
||||
- [ ] [knowledge-policy] 지식 소스, context 구성/압축, MCP/tool policy, validation/fallback 책임 경계가 IOP 책임으로 문서화되어 있다.
|
||||
- [ ] [web-search-context] web search는 RAG/context 최적화 후보에 포함되어 IOP 지식 소스 경계에서 다룬다.
|
||||
- [ ] [client-surface] NomadCode와 다른 외부 agent는 NomadCode 전용 metadata가 아니라 OpenAI-compatible, A2A, IOP native protocol 경계 중 맞는 표면으로 같은 IOP 책임 경계를 소비한다.
|
||||
- [ ] [shell-boundary] 특정 Agent Shell이나 Project Workspace UI 요구가 IOP 최적화 책임을 오염시키지 않도록 범위를 분리한다.
|
||||
- [ ] [validation-policy] 검증과 fallback은 모델 선택/라우팅 결과를 보완하는 IOP 내부 정책으로 정의한다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [ ] 지식 소스, context 구성, MCP/tool policy, validation/fallback 책임 경계가 문서화된다.
|
||||
- [ ] web search가 RAG/context 최적화 후보에 포함된다.
|
||||
- [ ] NomadCode와 다른 외부 agent가 같은 IOP 책임 경계를 소비할 수 있음이 설명된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 필수 Task와 완료 기준이 아직 모두 충족되지 않았다.
|
||||
- 완료 근거: 모든 기능 Task가 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -18,6 +19,7 @@ import (
|
|||
|
||||
type runService interface {
|
||||
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
|
||||
OllamaAPI(context.Context, edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
|
|
@ -50,6 +52,7 @@ func (s *Server) Start(ctx context.Context) error {
|
|||
mux.HandleFunc("/healthz", s.handleHealthz)
|
||||
mux.HandleFunc("/v1/models", s.handleModels)
|
||||
mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
|
||||
mux.HandleFunc("/api/", s.handleOllamaAPI)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: s.cfg.Listen,
|
||||
|
|
@ -140,6 +143,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||
Target: target,
|
||||
SessionID: s.resolveSessionID(),
|
||||
Prompt: prompt,
|
||||
Input: req.runInput(prompt),
|
||||
TimeoutSec: s.resolveTimeoutSec(),
|
||||
Metadata: map[string]string{
|
||||
"source": "openai",
|
||||
|
|
@ -160,6 +164,42 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||
s.completeChatCompletion(w, r, req, handle)
|
||||
}
|
||||
|
||||
func (s *Server) handleOllamaAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", "read request body failed")
|
||||
return
|
||||
}
|
||||
resp, err := s.service.OllamaAPI(r.Context(), edgeservice.OllamaAPIRequest{
|
||||
NodeRef: s.cfg.NodeRef,
|
||||
Adapter: s.resolveAdapter(),
|
||||
Method: r.Method,
|
||||
Path: r.URL.RequestURI(),
|
||||
Body: string(body),
|
||||
TimeoutSec: s.resolveTimeoutSec(),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "ollama_passthrough_error", err.Error())
|
||||
return
|
||||
}
|
||||
contentType := resp.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
status := resp.StatusCode
|
||||
if status < 100 || status > 999 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(resp.Body))
|
||||
}
|
||||
|
||||
func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) {
|
||||
text, reasoning, usage, err := collectRunResult(r.Context(), handle)
|
||||
if err != nil {
|
||||
|
|
@ -417,15 +457,23 @@ func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string)
|
|||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Format any `json:"format,omitempty"`
|
||||
KeepAlive any `json:"keep_alive,omitempty"`
|
||||
Think any `json:"think,omitempty"`
|
||||
Tools []any `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Images []any `json:"images,omitempty"`
|
||||
ToolCalls []any `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
}
|
||||
|
||||
func (m *chatMessage) UnmarshalJSON(b []byte) error {
|
||||
|
|
@ -433,6 +481,9 @@ func (m *chatMessage) UnmarshalJSON(b []byte) error {
|
|||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
Images []any `json:"images,omitempty"`
|
||||
ToolCalls []any `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
|
|
@ -440,9 +491,59 @@ func (m *chatMessage) UnmarshalJSON(b []byte) error {
|
|||
m.Role = raw.Role
|
||||
m.Content = contentToString(raw.Content)
|
||||
m.ReasoningContent = raw.ReasoningContent
|
||||
m.Images = raw.Images
|
||||
m.ToolCalls = raw.ToolCalls
|
||||
m.ToolName = raw.ToolName
|
||||
return nil
|
||||
}
|
||||
|
||||
func (req chatCompletionRequest) runInput(prompt string) map[string]any {
|
||||
input := map[string]any{
|
||||
"prompt": prompt,
|
||||
"messages": chatMessagesInput(req.Messages),
|
||||
}
|
||||
if len(req.Options) > 0 {
|
||||
input["options"] = req.Options
|
||||
}
|
||||
if req.Format != nil {
|
||||
input["format"] = req.Format
|
||||
}
|
||||
if req.KeepAlive != nil {
|
||||
input["keep_alive"] = req.KeepAlive
|
||||
}
|
||||
if req.Think != nil {
|
||||
input["think"] = req.Think
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
input["tools"] = req.Tools
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func chatMessagesInput(messages []chatMessage) []any {
|
||||
out := make([]any, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
m := map[string]any{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
}
|
||||
if msg.ReasoningContent != "" {
|
||||
m["thinking"] = msg.ReasoningContent
|
||||
}
|
||||
if len(msg.Images) > 0 {
|
||||
m["images"] = msg.Images
|
||||
}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
m["tool_calls"] = msg.ToolCalls
|
||||
}
|
||||
if msg.ToolName != "" {
|
||||
m["tool_name"] = msg.ToolName
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contentToString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import (
|
|||
)
|
||||
|
||||
type fakeRunService struct {
|
||||
req edgeservice.SubmitRunRequest
|
||||
events chan *iop.RunEvent
|
||||
req edgeservice.SubmitRunRequest
|
||||
ollamaReq edgeservice.OllamaAPIRequest
|
||||
ollamaResp edgeservice.OllamaAPIView
|
||||
events chan *iop.RunEvent
|
||||
}
|
||||
|
||||
func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
|
||||
|
|
@ -34,6 +36,14 @@ func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunR
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *fakeRunService) OllamaAPI(_ context.Context, req edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error) {
|
||||
s.ollamaReq = req
|
||||
if s.ollamaResp.StatusCode == 0 {
|
||||
s.ollamaResp.StatusCode = http.StatusOK
|
||||
}
|
||||
return s.ollamaResp, nil
|
||||
}
|
||||
|
||||
func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
|
||||
|
|
@ -101,6 +111,43 @@ func TestChatCompletionsUsesRequestModelWhenNoConfiguredTarget(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsPassesOllamaOptions(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"from-request",
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"options":{"temperature":0.2,"top_p":0.9,"num_predict":32,"stop":["END"]},
|
||||
"keep_alive":"10m",
|
||||
"think":false,
|
||||
"format":"json",
|
||||
"tools":[{"type":"function","function":{"name":"lookup"}}]
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
options, ok := fake.req.Input["options"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("options not passed: %+v", fake.req.Input)
|
||||
}
|
||||
if options["temperature"].(float64) != 0.2 || options["top_p"].(float64) != 0.9 || options["num_predict"].(float64) != 32 {
|
||||
t.Fatalf("unexpected options: %+v", options)
|
||||
}
|
||||
if fake.req.Input["keep_alive"] != "10m" || fake.req.Input["think"] != false || fake.req.Input["format"] != "json" {
|
||||
t.Fatalf("top-level ollama fields not passed: %+v", fake.req.Input)
|
||||
}
|
||||
if tools, ok := fake.req.Input["tools"].([]any); !ok || len(tools) != 1 {
|
||||
t.Fatalf("tools not passed: %+v", fake.req.Input["tools"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamsSSE(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"}
|
||||
|
|
@ -205,6 +252,34 @@ func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOllamaAPIPassthrough(t *testing.T) {
|
||||
fake := &fakeRunService{
|
||||
ollamaResp: edgeservice.OllamaAPIView{
|
||||
StatusCode: http.StatusAccepted,
|
||||
ContentType: "application/json",
|
||||
Body: `{"ok":true}`,
|
||||
},
|
||||
}
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", TimeoutSec: 15}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(`{"model":"gemma4:26b"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleOllamaAPI(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if fake.ollamaReq.Adapter != "ollama" || fake.ollamaReq.Method != http.MethodPost || fake.ollamaReq.Path != "/api/show" {
|
||||
t.Fatalf("passthrough req mismatch: %+v", fake.ollamaReq)
|
||||
}
|
||||
if fake.ollamaReq.Body != `{"model":"gemma4:26b"}` || fake.ollamaReq.TimeoutSec != 15 {
|
||||
t.Fatalf("passthrough body/timeout mismatch: %+v", fake.ollamaReq)
|
||||
}
|
||||
if w.Body.String() != `{"ok":true}` {
|
||||
t.Fatalf("body: got %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRunResultTimesOut(t *testing.T) {
|
||||
handle := &edgeservice.RunHandle{
|
||||
RunDispatch: edgeservice.RunDispatch{TimeoutSec: 1},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package service
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
toki "git.toki-labs.com/toki/proto-socket/go"
|
||||
|
|
@ -83,6 +84,7 @@ type SubmitRunRequest struct {
|
|||
Target string
|
||||
SessionID string
|
||||
Prompt string
|
||||
Input map[string]any
|
||||
Background bool
|
||||
TimeoutSec int
|
||||
Metadata map[string]string
|
||||
|
|
@ -380,6 +382,55 @@ func (s *Service) TransportStatus(_ context.Context, req NodeCommandRequestSpec)
|
|||
return s.sendNodeCommand(req, iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS, "transport")
|
||||
}
|
||||
|
||||
type OllamaAPIRequest struct {
|
||||
NodeRef string
|
||||
Adapter string
|
||||
Target string
|
||||
Method string
|
||||
Path string
|
||||
Body string
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
type OllamaAPIView struct {
|
||||
StatusCode int
|
||||
ContentType string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (s *Service) OllamaAPI(_ context.Context, req OllamaAPIRequest) (OllamaAPIView, error) {
|
||||
entry, err := s.ResolveNode(req.NodeRef)
|
||||
if err != nil {
|
||||
return OllamaAPIView{}, err
|
||||
}
|
||||
commandReq := buildNodeCommandRequest(iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API, "ollama", req.Adapter, req.Target, "", req.TimeoutSec)
|
||||
commandReq.Metadata = map[string]string{
|
||||
"ollama_method": req.Method,
|
||||
"ollama_path": req.Path,
|
||||
"ollama_body": req.Body,
|
||||
}
|
||||
resp, err := toki.SendRequestTyped[*iop.NodeCommandRequest, *iop.NodeCommandResponse](
|
||||
&entry.Client.Communicator,
|
||||
commandReq,
|
||||
StatusWaitTimeout(commandReq),
|
||||
)
|
||||
if err != nil {
|
||||
return OllamaAPIView{}, fmt.Errorf("transport error: %w", err)
|
||||
}
|
||||
if resp.GetError() != "" {
|
||||
return OllamaAPIView{}, fmt.Errorf("node reported error: %s", resp.GetError())
|
||||
}
|
||||
statusCode, _ := strconv.Atoi(resp.GetResult()["status_code"])
|
||||
if statusCode == 0 {
|
||||
statusCode = 200
|
||||
}
|
||||
return OllamaAPIView{
|
||||
StatusCode: statusCode,
|
||||
ContentType: resp.GetResult()["content_type"],
|
||||
Body: resp.GetResult()["body"],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) sendNodeCommand(req NodeCommandRequestSpec, cmdType iop.NodeCommandType, idPrefix string) (NodeCommandView, error) {
|
||||
entry, err := s.ResolveNode(req.NodeRef)
|
||||
if err != nil {
|
||||
|
|
@ -424,7 +475,16 @@ func IsNodeDisconnected(event *iop.EdgeNodeEvent) bool {
|
|||
}
|
||||
|
||||
func BuildRunRequest(req SubmitRunRequest) (*iop.RunRequest, string, error) {
|
||||
input, err := structpb.NewStruct(map[string]any{"prompt": req.Prompt})
|
||||
inputMap := make(map[string]any, len(req.Input)+1)
|
||||
for k, v := range req.Input {
|
||||
inputMap[k] = v
|
||||
}
|
||||
if req.Prompt != "" {
|
||||
if _, ok := inputMap["prompt"]; !ok {
|
||||
inputMap["prompt"] = req.Prompt
|
||||
}
|
||||
}
|
||||
input, err := structpb.NewStruct(inputMap)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,8 +77,20 @@ func (o *Ollama) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink r
|
|||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
if o.contextSize > 0 {
|
||||
chatReq.Options = &ollamaOptions{NumCtx: o.contextSize}
|
||||
if options := ollamaOptionsFromInput(spec.Input, o.contextSize); len(options) > 0 {
|
||||
chatReq.Options = options
|
||||
}
|
||||
if v, ok := spec.Input["format"]; ok {
|
||||
chatReq.Format = v
|
||||
}
|
||||
if v, ok := spec.Input["keep_alive"]; ok {
|
||||
chatReq.KeepAlive = v
|
||||
}
|
||||
if v, ok := spec.Input["think"]; ok {
|
||||
chatReq.Think = v
|
||||
}
|
||||
if v, ok := spec.Input["tools"]; ok {
|
||||
chatReq.Tools = v
|
||||
}
|
||||
body, err := json.Marshal(chatReq)
|
||||
if err != nil {
|
||||
|
|
@ -199,6 +211,53 @@ func (o *Ollama) fetchTargets() []string {
|
|||
return out
|
||||
}
|
||||
|
||||
func (o *Ollama) HandleCommand(ctx context.Context, req runtime.CommandRequest) (runtime.CommandResponse, error) {
|
||||
if req.Type != runtime.CommandTypeOllamaAPI {
|
||||
return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported command %q", req.Type)
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Metadata["ollama_method"]))
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if method != http.MethodGet && method != http.MethodPost && method != http.MethodDelete {
|
||||
return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported method %q", method)
|
||||
}
|
||||
path := strings.TrimSpace(req.Metadata["ollama_path"])
|
||||
if !strings.HasPrefix(path, "/api/") {
|
||||
return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: unsupported path %q", path)
|
||||
}
|
||||
body := req.Metadata["ollama_body"]
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, joinURLRequestURI(o.baseURL, path), reader)
|
||||
if err != nil {
|
||||
return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: build passthrough request: %w", err)
|
||||
}
|
||||
if body != "" {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := o.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return runtime.CommandResponse{}, fmt.Errorf("ollama adapter: passthrough request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody := readLimited(resp.Body, 16<<20)
|
||||
return runtime.CommandResponse{
|
||||
RequestID: req.RequestID,
|
||||
Type: req.Type,
|
||||
Adapter: req.Adapter,
|
||||
Target: req.Target,
|
||||
SessionID: req.SessionID,
|
||||
Result: map[string]string{
|
||||
"status_code": fmt.Sprintf("%d", resp.StatusCode),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
"body": respBody,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ollamaMessagesFromInput(input map[string]any) []ollamaMessage {
|
||||
if raw, ok := input["messages"].([]any); ok {
|
||||
out := make([]ollamaMessage, 0, len(raw))
|
||||
|
|
@ -209,12 +268,21 @@ func ollamaMessagesFromInput(input map[string]any) []ollamaMessage {
|
|||
}
|
||||
role, _ := m["role"].(string)
|
||||
content, _ := m["content"].(string)
|
||||
thinking, _ := m["thinking"].(string)
|
||||
toolName, _ := m["tool_name"].(string)
|
||||
role = strings.TrimSpace(role)
|
||||
content = strings.TrimSpace(content)
|
||||
if role == "" || content == "" {
|
||||
if role == "" || (content == "" && thinking == "") {
|
||||
continue
|
||||
}
|
||||
out = append(out, ollamaMessage{Role: role, Content: content})
|
||||
out = append(out, ollamaMessage{
|
||||
Role: role,
|
||||
Content: content,
|
||||
Thinking: thinking,
|
||||
Images: anySlice(m["images"]),
|
||||
ToolCalls: anySlice(m["tool_calls"]),
|
||||
ToolName: toolName,
|
||||
})
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
|
|
@ -227,6 +295,31 @@ func ollamaMessagesFromInput(input map[string]any) []ollamaMessage {
|
|||
return []ollamaMessage{{Role: "user", Content: prompt}}
|
||||
}
|
||||
|
||||
func ollamaOptionsFromInput(input map[string]any, contextSize int) map[string]any {
|
||||
options := map[string]any{}
|
||||
if raw, ok := input["options"].(map[string]any); ok {
|
||||
for k, v := range raw {
|
||||
options[k] = v
|
||||
}
|
||||
}
|
||||
if contextSize > 0 {
|
||||
if _, ok := options["num_ctx"]; !ok {
|
||||
options["num_ctx"] = contextSize
|
||||
}
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func anySlice(v any) []any {
|
||||
if items, ok := v.([]any); ok {
|
||||
return items
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringInput(input map[string]any, key string) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
|
|
@ -263,26 +356,44 @@ func joinURL(baseURL, path string) string {
|
|||
return u.String()
|
||||
}
|
||||
|
||||
func joinURLRequestURI(baseURL, requestURI string) string {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return strings.TrimRight(baseURL, "/") + requestURI
|
||||
}
|
||||
rel, err := url.Parse(requestURI)
|
||||
if err != nil {
|
||||
u.Path = strings.TrimRight(u.Path, "/") + requestURI
|
||||
return u.String()
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + rel.Path
|
||||
u.RawQuery = rel.RawQuery
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func readLimited(r io.Reader, limit int64) string {
|
||||
b, _ := io.ReadAll(io.LimitReader(r, limit))
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
type ollamaChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ollamaMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Options *ollamaOptions `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ollamaOptions struct {
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []ollamaMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Format any `json:"format,omitempty"`
|
||||
KeepAlive any `json:"keep_alive,omitempty"`
|
||||
Think any `json:"think,omitempty"`
|
||||
Tools any `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type ollamaMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Images []any `json:"images,omitempty"`
|
||||
ToolCalls []any `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
}
|
||||
|
||||
type ollamaChatChunk struct {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@ func TestOllamaExecuteStreamsChatDeltas(t *testing.T) {
|
|||
}
|
||||
gotModel = req.Model
|
||||
if req.Options != nil {
|
||||
gotNumCtx = req.Options.NumCtx
|
||||
switch v := req.Options["num_ctx"].(type) {
|
||||
case float64:
|
||||
gotNumCtx = int(v)
|
||||
case int:
|
||||
gotNumCtx = v
|
||||
}
|
||||
}
|
||||
if len(req.Messages) != 1 {
|
||||
t.Fatalf("expected one message, got %+v", req.Messages)
|
||||
|
|
@ -98,6 +103,57 @@ func TestOllamaExecuteStreamsChatDeltas(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOllamaExecutePassesOptionsAndTopLevelFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/chat" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
var req ollamaChatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if req.Options["temperature"].(float64) != 0.2 || req.Options["top_p"].(float64) != 0.9 || req.Options["num_predict"].(float64) != 32 {
|
||||
t.Fatalf("unexpected options: %+v", req.Options)
|
||||
}
|
||||
if req.Options["num_ctx"].(float64) != 4096 {
|
||||
t.Fatalf("request num_ctx should override config context size: %+v", req.Options)
|
||||
}
|
||||
if req.KeepAlive != "10m" || req.Think != false || req.Format != "json" {
|
||||
t.Fatalf("top-level fields not passed: keep_alive=%v think=%v format=%v", req.KeepAlive, req.Think, req.Format)
|
||||
}
|
||||
if tools, ok := req.Tools.([]any); !ok || len(tools) != 1 {
|
||||
t.Fatalf("tools not passed: %+v", req.Tools)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
_, _ = w.Write([]byte(`{"message":{"role":"assistant","content":"ok"},"done":false}` + "\n"))
|
||||
_, _ = w.Write([]byte(`{"done":true}` + "\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.OllamaConf{BaseURL: server.URL, ContextSize: 262144}, zap.NewNop())
|
||||
sink := &fakeSink{}
|
||||
err := adapter.Execute(context.Background(), noderuntime.ExecutionSpec{
|
||||
RunID: "run-options",
|
||||
Target: "llama-test",
|
||||
Input: map[string]any{
|
||||
"prompt": "hi",
|
||||
"options": map[string]any{
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.9,
|
||||
"num_predict": float64(32),
|
||||
"num_ctx": float64(4096),
|
||||
},
|
||||
"keep_alive": "10m",
|
||||
"think": false,
|
||||
"format": "json",
|
||||
"tools": []any{map[string]any{"type": "function"}},
|
||||
},
|
||||
}, sink)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaExecuteEmitsErrorForHTTPFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
|
|
@ -143,6 +199,43 @@ func TestOllamaCapabilitiesQueryTags(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOllamaHandleCommandPassesNativeAPI(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.RequestURI() != "/api/show?verbose=true" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["model"] != "gemma4:26b" {
|
||||
t.Fatalf("body not passed: %+v", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"license":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := New(config.OllamaConf{BaseURL: server.URL}, zap.NewNop())
|
||||
resp, err := adapter.HandleCommand(context.Background(), noderuntime.CommandRequest{
|
||||
RequestID: "ollama-1",
|
||||
Type: noderuntime.CommandTypeOllamaAPI,
|
||||
Adapter: "ollama",
|
||||
Metadata: map[string]string{
|
||||
"ollama_method": "POST",
|
||||
"ollama_path": "/api/show?verbose=true",
|
||||
"ollama_body": `{"model":"gemma4:26b"}`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCommand failed: %v", err)
|
||||
}
|
||||
if resp.Result["status_code"] != "202" || resp.Result["content_type"] != "application/json" || resp.Result["body"] != `{"license":"ok"}` {
|
||||
t.Fatalf("unexpected response: %+v", resp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaExecuteStreamsThinkingDeltasSeparately(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/chat" {
|
||||
|
|
|
|||
|
|
@ -309,6 +309,8 @@ func protoCommandTypeToDomain(t iop.NodeCommandType) (runtime.CommandType, bool)
|
|||
return runtime.CommandTypeSessionList, true
|
||||
case iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS:
|
||||
return runtime.CommandTypeTransportStatus, true
|
||||
case iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API:
|
||||
return runtime.CommandTypeOllamaAPI, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -324,6 +326,8 @@ func domainCommandTypeToProto(t runtime.CommandType) iop.NodeCommandType {
|
|||
return iop.NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST
|
||||
case runtime.CommandTypeTransportStatus:
|
||||
return iop.NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS
|
||||
case runtime.CommandTypeOllamaAPI:
|
||||
return iop.NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API
|
||||
default:
|
||||
return iop.NodeCommandType_NODE_COMMAND_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ const (
|
|||
CommandTypeCapabilities CommandType = "capabilities"
|
||||
CommandTypeSessionList CommandType = "session_list"
|
||||
CommandTypeTransportStatus CommandType = "transport_status"
|
||||
CommandTypeOllamaAPI CommandType = "ollama_api"
|
||||
)
|
||||
|
||||
type AgentUsageStatus struct {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ const (
|
|||
NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES NodeCommandType = 2
|
||||
NodeCommandType_NODE_COMMAND_TYPE_SESSION_LIST NodeCommandType = 3
|
||||
NodeCommandType_NODE_COMMAND_TYPE_TRANSPORT_STATUS NodeCommandType = 4
|
||||
NodeCommandType_NODE_COMMAND_TYPE_OLLAMA_API NodeCommandType = 5
|
||||
)
|
||||
|
||||
// Enum value maps for NodeCommandType.
|
||||
|
|
@ -138,6 +139,7 @@ var (
|
|||
2: "NODE_COMMAND_TYPE_CAPABILITIES",
|
||||
3: "NODE_COMMAND_TYPE_SESSION_LIST",
|
||||
4: "NODE_COMMAND_TYPE_TRANSPORT_STATUS",
|
||||
5: "NODE_COMMAND_TYPE_OLLAMA_API",
|
||||
}
|
||||
NodeCommandType_value = map[string]int32{
|
||||
"NODE_COMMAND_TYPE_UNSPECIFIED": 0,
|
||||
|
|
@ -145,6 +147,7 @@ var (
|
|||
"NODE_COMMAND_TYPE_CAPABILITIES": 2,
|
||||
"NODE_COMMAND_TYPE_SESSION_LIST": 3,
|
||||
"NODE_COMMAND_TYPE_TRANSPORT_STATUS": 4,
|
||||
"NODE_COMMAND_TYPE_OLLAMA_API": 5,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1881,13 +1884,14 @@ const file_proto_iop_runtime_proto_rawDesc = "" +
|
|||
"\fCancelAction\x12\x1d\n" +
|
||||
"\x19CANCEL_ACTION_UNSPECIFIED\x10\x00\x12\x1c\n" +
|
||||
"\x18CANCEL_ACTION_CANCEL_RUN\x10\x01\x12#\n" +
|
||||
"\x1fCANCEL_ACTION_TERMINATE_SESSION\x10\x02*\xc8\x01\n" +
|
||||
"\x1fCANCEL_ACTION_TERMINATE_SESSION\x10\x02*\xea\x01\n" +
|
||||
"\x0fNodeCommandType\x12!\n" +
|
||||
"\x1dNODE_COMMAND_TYPE_UNSPECIFIED\x10\x00\x12\"\n" +
|
||||
"\x1eNODE_COMMAND_TYPE_USAGE_STATUS\x10\x01\x12\"\n" +
|
||||
"\x1eNODE_COMMAND_TYPE_CAPABILITIES\x10\x02\x12\"\n" +
|
||||
"\x1eNODE_COMMAND_TYPE_SESSION_LIST\x10\x03\x12&\n" +
|
||||
"\"NODE_COMMAND_TYPE_TRANSPORT_STATUS\x10\x04B\x13Z\x11iop/proto/gen/iopb\x06proto3"
|
||||
"\"NODE_COMMAND_TYPE_TRANSPORT_STATUS\x10\x04\x12 \n" +
|
||||
"\x1cNODE_COMMAND_TYPE_OLLAMA_API\x10\x05B\x13Z\x11iop/proto/gen/iopb\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_iop_runtime_proto_rawDescOnce sync.Once
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ enum NodeCommandType {
|
|||
NODE_COMMAND_TYPE_CAPABILITIES = 2;
|
||||
NODE_COMMAND_TYPE_SESSION_LIST = 3;
|
||||
NODE_COMMAND_TYPE_TRANSPORT_STATUS = 4;
|
||||
NODE_COMMAND_TYPE_OLLAMA_API = 5;
|
||||
}
|
||||
|
||||
message NodeCommandRequest {
|
||||
|
|
|
|||
Loading…
Reference in a new issue